diff --git a/.github/android-release-signing-certificate.sha256 b/.github/android-release-signing-certificate.sha256 new file mode 100644 index 0000000..d940a1e --- /dev/null +++ b/.github/android-release-signing-certificate.sha256 @@ -0,0 +1 @@ +c2a2cde113ba53cafa2d111dc79698a4d2295256b1ad6e83e9acf3e8eb179384 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f99a5d..4dfb2c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,3 +113,46 @@ jobs: path: app/build/outputs/apk/debug/app-debug.apk if-no-files-found: error retention-days: 14 + + instrumented: + name: Instrumented tests (API 34) + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Check out source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up JDK 17 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + with: + distribution: temurin + java-version: "17" + + - name: Validate the Gradle wrapper + uses: gradle/actions/wrapper-validation@0f4528296b4bc09e8ae0fc7be30185a4ab435545 # v6.0.0 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@0f4528296b4bc09e8ae0fc7be30185a4ab435545 # v6.0.0 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run connected tests on the minimum supported Android version + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 + with: + api-level: "37.0" + system-image-api-level: 34 + target: google_apis + arch: x86_64 + profile: pixel_7_pro + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: ./gradlew --no-daemon connectedDebugAndroidTest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 142bb80..ba31645 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,16 +5,60 @@ on: tags: - "v*" -permissions: - contents: write - concurrency: group: android-release-${{ github.ref }} cancel-in-progress: false jobs: + instrumented: + name: Instrumented release gate (API 34) + permissions: + contents: read + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Check out source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up JDK 17 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + with: + distribution: temurin + java-version: "17" + + - name: Validate the Gradle wrapper + uses: gradle/actions/wrapper-validation@0f4528296b4bc09e8ae0fc7be30185a4ab435545 # v6.0.0 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@0f4528296b4bc09e8ae0fc7be30185a4ab435545 # v6.0.0 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run connected tests on the minimum supported Android version + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 + with: + api-level: "37.0" + system-image-api-level: 34 + target: google_apis + arch: x86_64 + profile: pixel_7_pro + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: ./gradlew --no-daemon connectedDebugAndroidTest + release: name: Verify, sign, and publish APK + needs: instrumented + permissions: + contents: write runs-on: ubuntu-latest timeout-minutes: 45 env: @@ -125,7 +169,7 @@ jobs: - name: Test, lint, and build signed release APK run: >- ./gradlew --no-daemon - test testDebugUnitTest lintDebug assembleRelease + test testDebugUnitTest lintDebug lintRelease assembleRelease -PreleaseVersionName=${{ steps.release_metadata.outputs.version_name }} -PreleaseVersionCode=${{ github.run_number }} @@ -136,15 +180,24 @@ jobs: python3 tools/validate_protocol_vectors.py python3 tools/retired_identity_audit.py python3 tools/collector_assurance.py + python3 -m unittest discover -s tools/tests -v ./gradlew --no-daemon \ -I tools/protocol-conformance.init.gradle \ :core:export:test \ --tests particeps.conformance.ProtocolConformanceTest + - name: Extract versioned release notes + shell: bash + run: >- + python3 tools/changelog_release_notes.py + "$GITHUB_REF_NAME" + "$RUNNER_TEMP/release-notes.md" + - name: Verify and package signed APK id: signed_apk shell: bash run: | + set -euo pipefail build_tools="$ANDROID_SDK_ROOT/build-tools/$ANDROID_BUILD_TOOLS_VERSION" signed_apk="app/build/outputs/apk/release/app-release.apk" release_dir="$RUNNER_TEMP/android-release" @@ -153,7 +206,7 @@ jobs: test -f "$signed_apk" mkdir -p "$release_dir" cp "$signed_apk" "$release_apk" - "$build_tools/apksigner" verify --verbose --print-certs "$release_apk" + python3 tools/verify_release_apk.py "$build_tools/apksigner" "$release_apk" ( cd "$release_dir" @@ -169,8 +222,10 @@ jobs: APK: ${{ steps.signed_apk.outputs.apk }} CHECKSUM: ${{ steps.signed_apk.outputs.checksum }} IS_PRERELEASE: ${{ steps.release_metadata.outputs.prerelease }} + RELEASE_NOTES: ${{ runner.temp }}/release-notes.md run: | if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release edit "$GITHUB_REF_NAME" --notes-file "$RELEASE_NOTES" gh release upload "$GITHUB_REF_NAME" "$APK" "$CHECKSUM" --clobber else release_kind=() @@ -179,7 +234,7 @@ jobs: fi gh release create "$GITHUB_REF_NAME" "$APK" "$CHECKSUM" \ --verify-tag \ - --generate-notes \ + --notes-file "$RELEASE_NOTES" \ --title "$GITHUB_REF_NAME" \ "${release_kind[@]}" fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c33281..b2bcc63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,68 @@ What changed between releases, and what each change asks of someone who already installed one. -This project is pre-1.0. Every release so far is a release candidate, and each one below changed -something that a device treats as identity — the application ID, the file formats, or the signing -certificate. None of them can update an earlier install in place. That is stated once here rather -than in each document that touches it. +This project is pre-1.0. Several early release candidates changed something that a device treats as +identity — the application ID, the file formats, or the signing certificate. Do not infer update +compatibility from the version number; each release below states what an existing installation +must do. + +## Unreleased + +## v1.0.0-rc.6 — 2026-08-09 + +- Notification access is required for every study because the daily status reminder and ongoing + collection notification are app-level guarantees, not features that depend on interventions. + The app checks the Android permission, the app-wide notification switch, and each channel the + study needs. It rechecks required access before both start and resume and from the running + foreground service. A failed setup, Start, or Resume preflight leaves `ACCESS_SETUP`, `READY`, or + `PAUSED` unchanged; required access lost after the study is already `RUNNING` creates the typed + safety pause. An optional source is blocked and resumed independently. +- Collector access capabilities now live in each collector's static descriptor. The access step + keeps the collector owners when shared access is de-duplicated, orders dependent operations, and + shows app-authored English and Traditional Chinese instructions for background location, Usage + Access, and research-keyboard setup. +- Background location is no longer requested through an Android runtime dialog that cannot grant + it. After precise location is granted, Particeps first verifies the signed study's exact Fused + Location request against Android settings, then explains the background behaviour and opens the + app's Android settings page for the participant to choose Android's localized background option + manually. +- Start and Resume now wait until Android has acknowledged the foreground service with its exact + service types before any source may emit. A whole-study safety loss closes admission and records + its closed reason in an identity-free typed marker; reason-bearing WorkManager retry survives a + process restart, and each optional source has its own fail-closed event gate. Required access uses + `REQUIRED_ACCESS_MISSING`. Once a study is durably running, losing every acknowledged foreground + host during a type change uses `COLLECTION_HOST_FAILURE`; an untrustworthy store mutation uses + `STORAGE_FAILURE`; and a failed or cancelled source release uses `COLLECTION_TEARDOWN_FAILURE`. + An unacknowledged deadline, reminder, upload, intervention, or retry mutation uses + `WORK_SCHEDULING_FAILURE`. WorkManager enqueue and cancellation must be acknowledged before the App treats + the durable handoff or retry retirement as complete, so Resume cannot race a stale safety worker. +- The signed duration is now an absolute ceiling measured from the one durable participant Start. + Resume, time change, and same-boot process recovery recompute and replace the deadline from that + boundary; they cannot grant a fresh duration. Collector and occurrence admission independently + reject every observation at or beyond the exact monotonic deadline, and the deadline worker + rechecks due-ness before completing, so delayed or early WorkManager execution cannot widen or + shorten the signed window. The app trusts only the monotonic clock from the + participant-start boot. Any active study observed in another boot session fails closed with + `WORK_SCHEDULING_FAILURE` before a foreground service or collector can reopen; wall time is never + used as a cross-boot fallback. +- Safety-critical documents no longer rely on Android `AtomicFile`, which can log an `fsync` or + rename failure without returning it. The repo-owned acknowledged writer keeps independently + durable `.pending` and `.replacement` copies, preserves the first as an uncertainty witness while + atomically replacing the base with the second, and acknowledges only after exact readback and + directory sync. Any leftover witness or unknown event-directory entry blocks recovery instead of + being guessed away. +- The release workflow now requires the final APK to have exactly one signer whose certificate + matches the [rc.5 production identity anchor](.github/android-release-signing-certificate.sha256). + A different or additional certificate stops publication. The emulator gate has read-only repository + permission; only the dependent APK publication job receives write permission. + +**Coming from `v1.0.0-rc.5`:** install the signed rc.6 APK over the existing app. The application ID +and release signing certificate are unchanged, so Android accepts it as an in-place update and the +active study and its local data remain in place. Do not uninstall rc.5 first. + +**Coming from `v1.0.0-rc.4` or earlier:** none of those builds can update to rc.6 in place. Follow +the release-specific note below and export anything worth retaining with tooling that supports that +release before uninstalling it. ## v1.0.0-rc.5 — 2026-08-07 @@ -14,8 +72,9 @@ than in each document that touches it. product. Either change alone stops a device accepting the build as an update; both apply. - The status line reports when a pause started and how long it has lasted. - One low-importance notification a day states whether the study is still collecting, or is paused - and since when. It names the application, never the study, so it discloses nothing to someone - reading a lock screen. Starting or stopping collection retracts a standing one. + and since when. It names the application and collection state, never the study; a lock-screen + reader can still infer that the phone uses Particeps. Starting or stopping collection retracts a + standing one. **Coming from `v1.0.0-rc.4`:** uninstall it. Its data cannot be migrated, and its exports are in the current format, so export anything worth keeping before you remove it and current tooling will read diff --git a/CITATION.cff b/CITATION.cff index 4496eb5..1065a63 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -33,5 +33,5 @@ license: MIT # (v1.0.0-rc.1 through v1.0.0-rc.3) carry the pre-rename identity, a different applicationId, and # different file formats; they are unsupported and are not versions of this software. Keep both # fields in step with the tag at every release. -version: 1.0.0-rc.5 -date-released: "2026-08-07" +version: 1.0.0-rc.6 +date-released: "2026-08-09" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b0af60..e7f26d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,7 @@ The debug APK lands at `app/build/outputs/apk/debug/app-debug.apk`. A clean chec With an emulator or device attached: ```bash -./gradlew :core:storage:connectedDebugAndroidTest :app:connectedDebugAndroidTest +./gradlew connectedDebugAndroidTest ``` The app suite separates the Android signed-configuration regression @@ -75,7 +75,10 @@ adb -s emulator-5554 emu sensor set proximity 1 -Pandroid.testInstrumentationRunnerArguments.p2SyntheticInputs=true ``` -CI runs unit tests, Android lint, and debug and release builds on every pull request. Please check those pass locally first. Note that `allWarningsAsErrors` is on, so an unhandled branch in an exhaustive `when` is a build failure rather than a warning. +CI runs unit tests, Android lint, debug and release builds, then the complete connected suite on an +API 34 Google APIs emulator on every pull request. Please check the host-side and attached-device +commands above locally first. Note that `allWarningsAsErrors` is on, so an unhandled branch in an +exhaustive `when` is a build failure rather than a warning. ### Tests diff --git a/README.md b/README.md index 48869d3..2ad562d 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ What the name does not grant is authorship of the study. The collector set, the 1. **Generate your keys.** One Ed25519 pair to sign study configurations, one X25519 HPKE pair to decrypt bundles. `researcher-tools` writes raw 32-byte keys as unpadded base64url. 2. **Write the study.** A strict Protocol v1 RFC 8785 JSON file naming collectors, reusable surveys, scheduled interventions, anonymous or assigned-code identity mode, duration, storage quota, consent text, and signing/export public keys. 3. **Sign it.** `researcher-tools sign` produces a `.partcfg` file that any build of the app can verify, with no change to the app. -4. **Distribute.** Participants install the app and import your `.partcfg`, or open a `particeps://join/v1` link that names where those exact bytes are served and pins their SHA-256. Setup is five steps, one screen each: the study details, what each enabled collector records and does not record, the consent text with the signer's key fingerprint, the Android access your collectors need, and the start button. Collection begins only when they press the start button. +4. **Distribute.** Participants install the app and import your `.partcfg`, or open a `particeps://join/v1` link that names where those exact bytes are served and pins their SHA-256. Setup is five steps, one screen each: the study details, what each enabled collector records and does not record, the consent text with the signer's key fingerprint, the Android access the study and its collectors need, and the start button. Collection begins only when they press the start button. 5. **Collect.** Events are written to encrypted on-device storage. Participants can pause, resume, finish early, or withdraw. 6. **Export and analyse.** The participant exports an encrypted bundle and sends it to you. If the study declares an upload endpoint, the app also delivers immutable ciphertext bundles to an R2 receiver on a schedule. `particeps-analysis` inventories, verifies, decrypts, reassembles, and writes typed Parquet offline. @@ -69,8 +69,9 @@ Studies collect from people's personal phones, so the platform is built to suppo - **Separated participant identities.** Every import gets a fresh random instance UUID. A configuration may additionally carry an opaque researcher-assigned code; both appear in the encrypted document. Upload URLs and headers contain no participant, assigned, experiment, or configuration ID. Their bundle UUID, configuration digest, researcher key ID, exact range/count, size, and digest are untrusted routing claims, not participant authentication. - **Encrypted, participant-directed export.** Getting data to the research team is an export the participant performs and directs, encrypted with a fresh key per export and wrapped to your HPKE public key. The app never holds your private key. - **Scheduled upload, when the study asks for it.** A configuration may name an HTTPS endpoint, interval, and metered-network policy. The endpoint host, cadence, and network condition are shown before consent. Before HTTP starts, the app durably stages one immutable ciphertext bundle in no-backup storage: about 16 MiB of plaintext and at most 32 MiB on the wire. Retries send those exact bytes with fixed length and digest. A bundle counts as delivered only when the receiver returns a receipt that matches the staged bundle exactly, so any other response leaves those events undelivered. [Protocol v1](protocol/v1/README.md) defines the receipt and which responses may advance the watermark. Finishing or withdrawing leaves delivery running until the tail arrives. Undelivered events are never reclaimed to make room. -- **Participant control over the lifecycle.** Collection starts only on an explicit action and can be paused, finished, or withdrawn. Pausing takes a monotonic boundary, so delayed callbacks cannot leak post-pause data into the dataset. -- **Storage failures stop collection.** Quota exhaustion or a write failure fail-closes the study to `PAUSED` rather than silently dropping events, so a dataset is complete over the window it declares or absent. +- **Participant control over the lifecycle.** Collection starts only on an explicit action and can be paused, finished, or withdrawn. The runtime takes a monotonic boundary, asks sources to release their callbacks, closes admission, waits for writes already admitted before that boundary, and only then commits the participant transition. A failed or cancelled teardown leaves a typed durable cleanup witness instead of reporting a clean boundary. +- **Storage failures stop collection.** Quota exhaustion or a write failure closes every event gate and persists a typed safety witness before the failing mutation returns. The study moves to `PAUSED` rather than silently dropping events, so a dataset is complete over the window it declares or absent. +- **The duration is an admission ceiling.** The deadline is derived from the one durable participant Start. Every collector and occurrence write rechecks its original observation time against that same-boot monotonic boundary, so a delayed WorkManager wake cannot admit post-deadline data. Resume, wall-clock change, and same-boot process recovery repair the completion job from the same origin. An active study found in another boot session cannot prove elapsed time, so it fails closed with `WORK_SCHEDULING_FAILURE` before collection reopens; wall time is never a fallback. ### Who published the study @@ -158,7 +159,7 @@ materialization in The [session](core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyApplication.kt) persists the occurrence before scheduling. The Android delivery and expiry workers in [`AndroidStudyPlatform.kt`](app/src/main/kotlin/cool/jacoblin/particeps/platform/AndroidStudyPlatform.kt) -and [`BootRecoveryReceiver`](app/src/main/kotlin/cool/jacoblin/particeps/BootRecoveryReceiver.kt) +and [`ScheduledWorkRecoveryReceiver`](app/src/main/kotlin/cool/jacoblin/particeps/ScheduledWorkRecoveryReceiver.kt) reconcile the same ID after retries, reboot, clock, or time-zone changes. The adjacent planner, runtime, session, and app policy tests make each boundary executable. @@ -185,11 +186,12 @@ New collectors are the main contribution path — see [CONTRIBUTING.md](CONTRIBU ## Coming from an earlier release candidate -Every release candidate published so far runs under a different application ID from the current -build, and the release signing key has since been rotated. Android treats each as an unrelated -application, so there is no upgrade and no migration. The older build keeps running under its own -name until it is removed. Uninstalling it destroys its Keystore key and everything encrypted under -it, so export whatever is still wanted first. Artifacts from before the rename are unsupported +`v1.0.0-rc.5` established the current application ID and the production signing certificate recorded +in the repository's [auditable identity anchor](.github/android-release-signing-certificate.sha256), +so rc.6 updates rc.5 in place. Rc.4 and earlier use another application ID, signing certificate, or +file identity and cannot update directly to the current build. Those older apps keep running under +their own identity until removed. Uninstalling destroys their Keystore key and everything encrypted +under it, so export whatever is still wanted first. Artifacts from before the rename are unsupported input to every current implementation, and there is no converter. [CHANGELOG.md](CHANGELOG.md) says which release carries which identity, which spellings it retired, and what each release asks of an existing install. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 03e87d3..3cbd9a3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -116,6 +116,7 @@ dependencies { debugImplementation(libs.compose.ui.test.manifest) testImplementation(libs.junit4) + testImplementation(libs.concurrent.futures) // Only to read the shared Protocol v1 corpus in a test; no production code parses JSON this way. testImplementation(libs.gson) diff --git a/app/src/androidTest/kotlin/cool/jacoblin/particeps/AccessCardTest.kt b/app/src/androidTest/kotlin/cool/jacoblin/particeps/AccessCardTest.kt new file mode 100644 index 0000000..bf56690 --- /dev/null +++ b/app/src/androidTest/kotlin/cool/jacoblin/particeps/AccessCardTest.kt @@ -0,0 +1,241 @@ +package cool.jacoblin.particeps + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import cool.jacoblin.particeps.core.application.StudyAccessOwner +import cool.jacoblin.particeps.core.application.StudyAccessStatus +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.collector.AccessResolution +import cool.jacoblin.particeps.core.collector.AccessUnavailableReason +import cool.jacoblin.particeps.core.collector.SetupAction +import cool.jacoblin.particeps.core.collector.SetupGuidance +import cool.jacoblin.particeps.core.definition.CollectorConfiguration +import cool.jacoblin.particeps.core.definition.ExportConfiguration +import cool.jacoblin.particeps.core.definition.LocationConfiguration +import cool.jacoblin.particeps.core.definition.LocationPriority +import cool.jacoblin.particeps.core.definition.NetworkTransport +import cool.jacoblin.particeps.core.definition.NetworkUsageConfiguration +import cool.jacoblin.particeps.core.definition.SignerIdentity +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.definition.UsageEventsConfiguration +import java.time.Instant +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AccessCardTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun backgroundLocationShowsManualStepsAndWaitsForPreciseLocation() { + val location = LocationConfiguration( + required = false, + intervalMillis = 10_000, + minimumIntervalMillis = 5_000, + maximumBatchDelayMillis = 30_000, + minimumDisplacementMillimeters = 5_000, + priority = LocationPriority.BALANCED, + ) + val check = StudyAccessStatus( + requirement = AccessRequirement(AccessKind.BACKGROUND_LOCATION, required = false), + owners = setOf(StudyAccessOwner.Collector(location.id, required = false)), + resolution = AccessResolution.BlockedByPrerequisites( + setOf(AccessKind.FINE_LOCATION, AccessKind.LOCATION_SERVICES), + ), + guidance = SetupGuidance.BACKGROUND_LOCATION, + ) + + composeRule.setContent { + MaterialTheme { + AccessCard(configuration(listOf(location)), check, actions(), busy = false) + } + } + + val context = InstrumentationRegistry.getInstrumentation().targetContext + composeRule.onNodeWithTag(UiTags.accessInstructions(AccessKind.BACKGROUND_LOCATION)).assertExists() + composeRule.onNodeWithText(context.getString(R.string.access_background_location_description)).assertExists() + composeRule.onNodeWithText( + context.getString( + R.string.access_background_location_step_choose_always, + context.packageManager.backgroundPermissionOptionLabel, + ), + substring = true, + ).assertExists() + composeRule.onNodeWithText( + context.getString( + R.string.access_complete_first, + context.getString(R.string.access_fine_location), + ), + ).assertExists() + composeRule.onNodeWithText( + context.getString( + R.string.access_complete_first, + context.getString(R.string.access_location_services), + ), + ).assertExists() + composeRule.onNodeWithTag(UiTags.accessAction(AccessKind.BACKGROUND_LOCATION)).assertDoesNotExist() + } + + @Test + fun sharedUsageAccessIsOneActionWithBothCollectorOwners() { + val networkUsage = NetworkUsageConfiguration( + required = true, + transports = setOf(NetworkTransport.WIFI), + pollIntervalMinutes = 5, + ) + val usageEvents = UsageEventsConfiguration(required = false, pollIntervalMinutes = 15) + val expectedAction = SetupAction.SystemSettings.USAGE_ACCESS + var launchedAction: SetupAction? = null + val check = StudyAccessStatus( + requirement = AccessRequirement(AccessKind.USAGE_ACCESS, required = true), + owners = setOf( + StudyAccessOwner.Collector(networkUsage.id, required = true), + StudyAccessOwner.Collector(usageEvents.id, required = false), + ), + resolution = AccessResolution.ActionRequired(expectedAction), + guidance = SetupGuidance.USAGE_ACCESS, + ) + + composeRule.setContent { + MaterialTheme { + AccessCard( + configuration(listOf(networkUsage, usageEvents)), + check, + actions { launchedAction = it }, + busy = false, + ) + } + } + + val context = InstrumentationRegistry.getInstrumentation().targetContext + composeRule.onNodeWithTag(UiTags.accessOwners(AccessKind.USAGE_ACCESS)).assertExists() + composeRule.onNodeWithText( + context.getString( + R.string.access_owner_item, + context.getString(R.string.collector_network_usage_name), + ), + ).assertExists() + composeRule.onNodeWithText( + context.getString( + R.string.access_owner_item, + context.getString(R.string.collector_usage_events_name), + ), + ).assertExists() + composeRule.onNodeWithTag(UiTags.accessAction(AccessKind.USAGE_ACCESS)).performClick() + assertEquals(expectedAction, launchedAction) + } + + @Test + fun unavailableLocationCheckShowsOnlyTheFailureNotActionableSteps() { + val location = LocationConfiguration( + required = true, + intervalMillis = 10_000, + minimumIntervalMillis = 5_000, + maximumBatchDelayMillis = 30_000, + minimumDisplacementMillimeters = 5_000, + priority = LocationPriority.BALANCED, + ) + val check = StudyAccessStatus( + requirement = AccessRequirement(AccessKind.LOCATION_SERVICES, required = true), + owners = setOf(StudyAccessOwner.Collector(location.id, required = true)), + resolution = AccessResolution.Unavailable( + AccessUnavailableReason.LOCATION_SETTINGS_CHECK_FAILED, + ), + guidance = SetupGuidance.LOCATION_SERVICES, + ) + + composeRule.setContent { + MaterialTheme { + AccessCard(configuration(listOf(location)), check, actions(), busy = false) + } + } + + val context = InstrumentationRegistry.getInstrumentation().targetContext + composeRule.onNodeWithText(context.getString(R.string.access_location_settings_check_failed)).assertExists() + composeRule.onNodeWithTag(UiTags.accessInstructions(AccessKind.LOCATION_SERVICES)).assertDoesNotExist() + composeRule.onNodeWithTag(UiTags.accessAction(AccessKind.LOCATION_SERVICES)).assertDoesNotExist() + } + + @Test + fun runningStudyCanRepairOptionalAccessWithoutLeavingCollectionControls() { + val usageEvents = UsageEventsConfiguration(required = false, pollIntervalMinutes = 15) + val expectedAction = SetupAction.SystemSettings.USAGE_ACCESS + var launchedAction: SetupAction? = null + val check = StudyAccessStatus( + requirement = AccessRequirement(AccessKind.USAGE_ACCESS, required = false), + owners = setOf(StudyAccessOwner.Collector(usageEvents.id, required = false)), + resolution = AccessResolution.ActionRequired(expectedAction), + guidance = SetupGuidance.USAGE_ACCESS, + ) + + composeRule.setContent { + MaterialTheme { + OptionalAccessRemediation( + configuration = configuration(listOf(usageEvents)), + checks = listOf(check), + actions = actions { launchedAction = it }, + busy = false, + ) + } + } + + val context = InstrumentationRegistry.getInstrumentation().targetContext + composeRule.onNodeWithText(context.getString(R.string.optional_access_title)).assertExists() + composeRule.onNodeWithTag(UiTags.accessAction(AccessKind.USAGE_ACCESS)).performClick() + assertEquals(expectedAction, launchedAction) + } + + private fun actions(requestAccess: (SetupAction) -> Unit = {}) = StudyUiActions( + import = {}, + demo = null, + review = {}, + acceptConsent = {}, + completeAccess = {}, + requestAccess = requestAccess, + start = {}, + pause = {}, + resume = {}, + finish = {}, + withdraw = {}, + export = {}, + delete = {}, + ) + + private fun configuration(collectors: List) = StudyConfiguration( + schemaVersion = StudyConfiguration.CURRENT_SCHEMA_VERSION, + experimentId = "access-card-test", + configurationId = "access-card-config", + assignedParticipantId = null, + issuedAt = Instant.parse("2026-01-01T00:00:00Z"), + expiresAt = Instant.parse("2030-01-01T00:00:00Z"), + platform = StudyConfiguration.ANDROID_PLATFORM, + minimumClientVersion = 1, + title = "Access card test", + researcherName = "Test researcher", + researcherContact = "test@example.invalid", + purpose = "Access UI test", + durationHours = 1, + consentDocumentVersion = "test-1", + consentSummary = "Test consent", + collectors = collectors, + surveys = emptyList(), + interventions = emptyList(), + maximumLocalBytes = StudyConfiguration.MINIMUM_LOCAL_BYTES, + signer = SignerIdentity("test-signer", RAW_PUBLIC_KEY), + export = ExportConfiguration("export-key", RAW_PUBLIC_KEY), + upload = null, + ) + + private companion object { + const val RAW_PUBLIC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } +} diff --git a/app/src/androidTest/kotlin/cool/jacoblin/particeps/AndroidConfigurationImportTest.kt b/app/src/androidTest/kotlin/cool/jacoblin/particeps/AndroidConfigurationImportTest.kt index 279737e..58602ab 100644 --- a/app/src/androidTest/kotlin/cool/jacoblin/particeps/AndroidConfigurationImportTest.kt +++ b/app/src/androidTest/kotlin/cool/jacoblin/particeps/AndroidConfigurationImportTest.kt @@ -4,9 +4,7 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import cool.jacoblin.particeps.core.model.ExperimentState import cool.jacoblin.particeps.core.storage.EncryptedActiveStudyStore -import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -23,7 +21,7 @@ class AndroidConfigurationImportTest { fun debugDemoImportsIntoTheAndroidSession() = runBlocking { val application = ApplicationProvider.getApplicationContext() val session = application.session - withTimeout(TIMEOUT_MILLIS) { session.snapshot.first { it.initialized } } + session.clearStudyDataForTest() assertNull("test requires a clean study session", session.snapshot.value.configuration) try { @@ -32,10 +30,7 @@ class AndroidConfigurationImportTest { assertEquals(ExperimentState.IMPORTED, session.snapshot.value.runtime.metadata?.state) } finally { - if (session.snapshot.value.configuration != null) { - session.withdraw() - session.deleteLocalData() - } + session.clearStudyDataForTest() } } @@ -49,26 +44,28 @@ class AndroidConfigurationImportTest { fun theRetiredConfigurationMagicFailsClosedInTheParser() = runBlocking { val application = ApplicationProvider.getApplicationContext() val session = application.session - withTimeout(TIMEOUT_MILLIS) { session.snapshot.first { it.initialized } } + session.clearStudyDataForTest() assertNull("test requires a clean study session", session.snapshot.value.configuration) - val loadDemo = requireNotNull(DemoStudy.load) - val envelope = loadDemo(application.resources).also { RETIRED_CONFIGURATION_MAGIC.copyInto(it) } + try { + val loadDemo = requireNotNull(DemoStudy.load) + val envelope = loadDemo(application.resources).also { RETIRED_CONFIGURATION_MAGIC.copyInto(it) } - val failure = runCatching { session.importSignedConfiguration(envelope) }.exceptionOrNull() + val failure = runCatching { session.importSignedConfiguration(envelope) }.exceptionOrNull() - assertNotNull("the retired magic must not import", failure) - assertNull(session.snapshot.value.configuration) - assertNull(session.snapshot.value.runtime.metadata) - assertNull( - "a refused import must persist no active study", - EncryptedActiveStudyStore(application).load(), - ) + assertNotNull("the retired magic must not import", failure) + assertNull(session.snapshot.value.configuration) + assertNull(session.snapshot.value.runtime.metadata) + assertNull( + "a refused import must persist no active study", + EncryptedActiveStudyStore(application).load(), + ) + } finally { + session.clearStudyDataForTest() + } } private companion object { - const val TIMEOUT_MILLIS = 20_000L - /** * Retired-identity rejection fixture: the pre-Particeps signed-configuration magic, kept * here only as bytes the import path has to refuse. Nothing in this repository writes it. diff --git a/app/src/androidTest/kotlin/cool/jacoblin/particeps/CoreFlowTest.kt b/app/src/androidTest/kotlin/cool/jacoblin/particeps/CoreFlowTest.kt index 6158c2b..8a0baff 100644 --- a/app/src/androidTest/kotlin/cool/jacoblin/particeps/CoreFlowTest.kt +++ b/app/src/androidTest/kotlin/cool/jacoblin/particeps/CoreFlowTest.kt @@ -1,7 +1,13 @@ package cool.jacoblin.particeps import android.Manifest +import android.app.NotificationManager +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isEnabled import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -10,11 +16,14 @@ import androidx.compose.ui.test.performScrollTo import androidx.lifecycle.Lifecycle import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import cool.jacoblin.particeps.core.collector.AccessKind import cool.jacoblin.particeps.core.collector.CollectorStatus import cool.jacoblin.particeps.core.model.ExperimentState import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.After +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -24,10 +33,16 @@ class CoreFlowTest { @get:Rule val composeRule = createAndroidComposeRule() + @Before + fun clearStudyBeforeTest() = runBlocking { session().clearStudyDataForTest() } + + @After + fun clearStudyAfterTest() = runBlocking { session().clearStudyDataForTest() } + @Test fun fullParticipantFlowRunsModularCollectorsAndHonorsPause() { - val session = (composeRule.activity.application as CollectorApplication).session - composeRule.waitUntil(TIMEOUT_MILLIS) { session.snapshot.value.initialized } + val session = session() + waitUntilExactlyOneNode(hasTestTag(UiTags.IMPORT_DEMO)) composeRule.onNodeWithTag(UiTags.IMPORT_DEMO).performScrollTo().performClick() composeRule.waitUntil(TIMEOUT_MILLIS) { val snapshot = session.snapshot.value @@ -53,16 +68,20 @@ class CoreFlowTest { session.snapshot.value.runtime.metadata?.state == ExperimentState.ACCESS_SETUP } + val notificationAccess = session.snapshot.value.access + .single { it.requirement.kind == AccessKind.NOTIFICATIONS } + assertTrue(notificationAccess.requirement.required) + val instrumentation = InstrumentationRegistry.getInstrumentation() instrumentation.uiAutomation.grantRuntimePermission( instrumentation.targetContext.packageName, Manifest.permission.POST_NOTIFICATIONS, ) - composeRule.activityRule.scenario.onActivity { session.refreshAccess() } + runBlocking { session.reconcileAccess() } composeRule.waitUntil(TIMEOUT_MILLIS) { session.snapshot.value.access.none { it.requirement.required && !it.granted } } - composeRule.onNodeWithTag(UiTags.ACCESS_COMPLETE).performScrollTo().performClick() + composeRule.onNodeWithTag(UiTags.ACCESS_COMPLETE).performScrollTo().assertIsEnabled().performClick() composeRule.waitUntil(TIMEOUT_MILLIS) { session.snapshot.value.runtime.metadata?.state == ExperimentState.READY } @@ -76,9 +95,18 @@ class CoreFlowTest { composeRule.activityRule.scenario.moveToState(Lifecycle.State.CREATED) composeRule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) - composeRule.waitUntil(TIMEOUT_MILLIS) { - val ids = session.snapshot.value.runtime.metadata?.lastEvents.orEmpty().keys - "app_lifecycle.v1" in ids && "accelerometer.v1" in ids && "network_state.v1" in ids + try { + composeRule.waitUntil(TIMEOUT_MILLIS) { + val ids = session.snapshot.value.runtime.metadata?.lastEvents.orEmpty().keys + "app_lifecycle.v1" in ids && "accelerometer.v1" in ids && "network_state.v1" in ids + } + } catch (failure: androidx.compose.ui.test.ComposeTimeoutException) { + val snapshot = session.snapshot.value + throw AssertionError( + "Collector events did not arrive; lastEvents=${snapshot.runtime.metadata?.lastEvents?.keys}, " + + "health=${snapshot.runtime.collectorHealth}, incident=${snapshot.incidentCode}", + failure, + ) } composeRule.onNodeWithTag(UiTags.PAUSE).performScrollTo().performClick() @@ -92,12 +120,38 @@ class CoreFlowTest { composeRule.waitForIdle() assertEquals(countAtPause, session.snapshot.value.runtime.metadata?.eventCount) + // Revoking a runtime permission kills the target process by design, which would also kill + // this in-process instrumentation test. Removing one required app-owned channel exercises + // the same closed notification-access gate without invalidating the test harness. + setRequiredNotificationChannelAvailable(instrumentation, available = false) + try { + runBlocking { session.reconcileAccess() } + composeRule.waitUntil(TIMEOUT_MILLIS) { + session.snapshot.value.access.any { + it.requirement.kind == AccessKind.NOTIFICATIONS && !it.granted + } + } + composeRule.onNodeWithTag(UiTags.accessAction(AccessKind.NOTIFICATIONS)).assertExists() + composeRule.onNodeWithTag(UiTags.ACCESS_COMPLETE).assertDoesNotExist() + composeRule.onNodeWithTag(UiTags.STATE) + .assertTextEquals(composeRule.activity.getString(R.string.state_paused)) + } finally { + setRequiredNotificationChannelAvailable(instrumentation, available = true) + } + runBlocking { session.reconcileAccess() } + composeRule.waitUntil(TIMEOUT_MILLIS) { + session.snapshot.value.access.none { it.requirement.required && !it.granted } + } + composeRule.onNodeWithTag(UiTags.RESUME).performScrollTo().performClick() composeRule.waitUntil(TIMEOUT_MILLIS) { session.snapshot.value.runtime.metadata?.state == ExperimentState.RUNNING } + waitUntilExactlyOneNode(hasTestTag(UiTags.FINISH) and isEnabled()) composeRule.onNodeWithTag(UiTags.FINISH).performScrollTo().performClick() - composeRule.onNodeWithText(composeRule.activity.getString(R.string.action_confirm)).performClick() + val confirm = composeRule.activity.getString(R.string.action_confirm) + waitUntilExactlyOneNode(hasText(confirm)) + composeRule.onNodeWithText(confirm).performClick() composeRule.waitUntil(TIMEOUT_MILLIS) { session.snapshot.value.runtime.metadata?.state == ExperimentState.COMPLETED } @@ -109,7 +163,28 @@ class CoreFlowTest { runBlocking { session.deleteLocalData() } } + private fun session() = (composeRule.activity.application as CollectorApplication).session + + private fun waitUntilExactlyOneNode(matcher: SemanticsMatcher) { + composeRule.waitUntil(TIMEOUT_MILLIS) { + composeRule.onAllNodes(matcher).fetchSemanticsNodes().size == 1 + } + } + + private fun setRequiredNotificationChannelAvailable( + instrumentation: android.app.Instrumentation, + available: Boolean, + ) { + val context = instrumentation.targetContext + if (available) { + ParticepsNotificationChannels.ensureCreated(context) + } else { + context.getSystemService(NotificationManager::class.java) + .deleteNotificationChannel(ParticepsNotificationChannels.DAILY_STATUS) + } + } + private companion object { - const val TIMEOUT_MILLIS = 20_000L + const val TIMEOUT_MILLIS = 40_000L } } diff --git a/app/src/androidTest/kotlin/cool/jacoblin/particeps/StudySessionTestIsolation.kt b/app/src/androidTest/kotlin/cool/jacoblin/particeps/StudySessionTestIsolation.kt new file mode 100644 index 0000000..7f5d475 --- /dev/null +++ b/app/src/androidTest/kotlin/cool/jacoblin/particeps/StudySessionTestIsolation.kt @@ -0,0 +1,32 @@ +package cool.jacoblin.particeps + +import cool.jacoblin.particeps.core.application.StudySessionManager +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.runtime.CommandResult +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull + +/** Restores the process-wide application session to the state every stateful test owns. */ +internal suspend fun StudySessionManager.clearStudyDataForTest() { + withTimeout(TEST_SESSION_TIMEOUT_MILLIS) { snapshot.first { it.initialized } } + + if (snapshot.value.configuration != null) { + val state = requireNotNull(snapshot.value.runtime.metadata).state + if (state != ExperimentState.COMPLETED && state != ExperimentState.WITHDRAWN) { + assertEquals( + "Failed to withdraw the study left in the shared instrumentation process", + CommandResult.Success, + withdraw(), + ) + } + deleteLocalData() + } + + assertNull("Instrumentation-test cleanup left an active study", snapshot.value.configuration) + assertFalse("Instrumentation-test cleanup left deletion pending", snapshot.value.deletionPending) +} + +private const val TEST_SESSION_TIMEOUT_MILLIS = 40_000L diff --git a/app/src/androidTest/kotlin/cool/jacoblin/particeps/WorkManagerSafetyPauseIntegrationTest.kt b/app/src/androidTest/kotlin/cool/jacoblin/particeps/WorkManagerSafetyPauseIntegrationTest.kt new file mode 100644 index 0000000..29c811f --- /dev/null +++ b/app/src/androidTest/kotlin/cool/jacoblin/particeps/WorkManagerSafetyPauseIntegrationTest.kt @@ -0,0 +1,104 @@ +package cool.jacoblin.particeps + +import android.os.SystemClock +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.WorkInfo +import androidx.work.WorkManager +import cool.jacoblin.particeps.core.model.SafetyPauseReason +import cool.jacoblin.particeps.platform.AndroidStudyWorkScheduler +import cool.jacoblin.particeps.platform.AtomicSafetyPauseStore +import cool.jacoblin.particeps.platform.SafetyPauseWorkIdentity +import cool.jacoblin.particeps.platform.awaitWorkPersistence +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** Exercises the production WorkManager database and the worker's acknowledged self-retirement. */ +@RunWith(AndroidJUnit4::class) +class WorkManagerSafetyPauseIntegrationTest { + @Test + fun enqueueAcknowledgementIsQueryableAndWorkerRetiresItsOwnDurableWitness() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val application = context.applicationContext as CollectorApplication + val session = application.session + + val workManager = WorkManager.getInstance(context) + val scheduler = AndroidStudyWorkScheduler(context) + val marker = AtomicSafetyPauseStore(context) + val reason = SafetyPauseReason.REQUIRED_ACCESS_MISSING + session.clearStudyDataForTest() + awaitWorkPersistence(workManager.cancelAllWorkByTag(SafetyPauseWorkIdentity.COMMON_TAG)) + marker.clear() + assertNull("The integration test requires an isolated app data directory", session.snapshot.value.configuration) + try { + session.importSignedConfiguration(requireNotNull(DemoStudy.load)(context.resources)) + val experimentId = requireNotNull(session.snapshot.value.configuration).experimentId + val workName = SafetyPauseWorkIdentity.workName(experimentId, reason) + val existingIds = workManager.getWorkInfosForUniqueWork(workName) + .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .mapTo(mutableSetOf()) { it.id } + + marker.markPending(reason) + + // This method does not return until AndroidX WorkManager acknowledges its real Room + // transaction. Therefore its newly inserted row must already be queryable here. + scheduler.scheduleSafetyPauseRetry(experimentId, reason) + val inserted = workManager.getWorkInfosForUniqueWork(workName) + .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .filterNot { it.id in existingIds } + assertEquals("One acknowledged unique-work row must be committed", 1, inserted.size) + val insertedId = inserted.single().id + assertTrue(SafetyPauseWorkIdentity.COMMON_TAG in inserted.single().tags) + + val terminal = awaitWorkInfo(workManager, insertedId) { it.state.isFinished } + + // Clearing the durable marker asks WorkManager to cancel the common tag while this + // worker is itself running. The NonCancellable acknowledgement path must let that + // cancellation transaction commit, leaving no active retry witness behind. + assertEquals(WorkInfo.State.CANCELLED, terminal.state) + assertNull(marker.pendingReason()) + assertNull(session.snapshot.value.safetyPauseStatus) + assertNull(scheduler.pendingSafetyPauseReason(experimentId)) + assertFalse( + workManager.getWorkInfosByTag(SafetyPauseWorkIdentity.COMMON_TAG) + .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .any { !it.state.isFinished }, + ) + } finally { + awaitWorkPersistence(workManager.cancelAllWorkByTag(SafetyPauseWorkIdentity.COMMON_TAG)) + marker.clear() + session.clearStudyDataForTest() + } + } + + private suspend fun awaitWorkInfo( + workManager: WorkManager, + id: UUID, + predicate: (WorkInfo) -> Boolean, + ): WorkInfo { + val deadline = SystemClock.elapsedRealtime() + TIMEOUT_MILLIS + while (SystemClock.elapsedRealtime() < deadline) { + val info = workManager.getWorkInfoById(id) + .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + if (info != null && predicate(info)) return info + delay(POLL_INTERVAL_MILLIS) + } + val last = workManager.getWorkInfoById(id) + .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + error("Work $id did not reach its expected state; last=$last") + } + + private companion object { + const val TIMEOUT_MILLIS = 30_000L + const val POLL_INTERVAL_MILLIS = 50L + const val FUTURE_TIMEOUT_SECONDS = 5L + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4da9e42..4f6de79 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,6 +18,27 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/cool/jacoblin/particeps/AccessPresentation.kt b/app/src/main/kotlin/cool/jacoblin/particeps/AccessPresentation.kt new file mode 100644 index 0000000..8560f45 --- /dev/null +++ b/app/src/main/kotlin/cool/jacoblin/particeps/AccessPresentation.kt @@ -0,0 +1,102 @@ +package cool.jacoblin.particeps + +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessUnavailableReason +import cool.jacoblin.particeps.core.collector.SetupAction +import cool.jacoblin.particeps.core.collector.SetupGuidance + +internal data class SetupGuidancePresentation( + val descriptionRes: Int, + val stepResources: List, +) { + init { + require(stepResources.isNotEmpty()) { "Setup guidance must contain at least one step" } + require(stepResources.distinct().size == stepResources.size) { "Setup guidance steps must be unique" } + } +} + +internal fun AccessKind.labelRes(): Int = when (this) { + AccessKind.FINE_LOCATION -> R.string.access_fine_location + AccessKind.LOCATION_SERVICES -> R.string.access_location_services + AccessKind.BACKGROUND_LOCATION -> R.string.access_background_location + AccessKind.NOTIFICATIONS -> R.string.access_notifications + AccessKind.USAGE_ACCESS -> R.string.access_usage_access + AccessKind.RESEARCH_KEYBOARD_ENABLED -> R.string.access_research_keyboard_enabled + AccessKind.RESEARCH_KEYBOARD_SELECTED -> R.string.access_research_keyboard_selected + AccessKind.ACCELEROMETER_HARDWARE -> R.string.access_accelerometer_hardware + AccessKind.GYROSCOPE_HARDWARE -> R.string.access_gyroscope_hardware + AccessKind.AMBIENT_LIGHT_HARDWARE -> R.string.access_ambient_light_hardware + AccessKind.PROXIMITY_HARDWARE -> R.string.access_proximity_hardware +} + +internal fun SetupAction.labelRes(): Int = when (this) { + is SetupAction.RuntimePermission -> R.string.action_allow_access + is SetupAction.SystemSettings -> R.string.action_open_settings + SetupAction.ShowInputMethodPicker -> R.string.action_choose_keyboard +} + +internal fun SetupGuidance.presentation(): SetupGuidancePresentation = when (this) { + SetupGuidance.FOREGROUND_LOCATION_SETTINGS -> SetupGuidancePresentation( + descriptionRes = R.string.access_fine_location_settings_description, + stepResources = listOf( + R.string.access_fine_location_settings_step_permissions, + R.string.access_fine_location_settings_step_precise, + R.string.access_fine_location_settings_step_return, + ), + ) + SetupGuidance.LOCATION_SERVICES -> SetupGuidancePresentation( + descriptionRes = R.string.access_location_services_description, + stepResources = listOf( + R.string.access_location_services_step_enable, + R.string.access_location_services_step_return, + ), + ) + SetupGuidance.BACKGROUND_LOCATION -> SetupGuidancePresentation( + descriptionRes = R.string.access_background_location_description, + stepResources = listOf( + R.string.access_background_location_step_open_permissions, + R.string.access_background_location_step_choose_always, + R.string.access_background_location_step_return, + ), + ) + SetupGuidance.NOTIFICATIONS_SETTINGS -> SetupGuidancePresentation( + descriptionRes = R.string.access_notifications_settings_description, + stepResources = listOf( + R.string.access_notifications_settings_step_enable, + R.string.access_notifications_settings_step_channels, + R.string.access_notifications_settings_step_return, + ), + ) + SetupGuidance.USAGE_ACCESS -> SetupGuidancePresentation( + descriptionRes = R.string.access_usage_access_description, + stepResources = listOf( + R.string.access_usage_access_step_choose_app, + R.string.access_usage_access_step_enable, + R.string.access_usage_access_step_return, + ), + ) + SetupGuidance.RESEARCH_KEYBOARD_ENABLE -> SetupGuidancePresentation( + descriptionRes = R.string.access_keyboard_enable_description, + stepResources = listOf( + R.string.access_keyboard_enable_step_manage, + R.string.access_keyboard_enable_step_enable, + R.string.access_keyboard_enable_step_return, + ), + ) + SetupGuidance.RESEARCH_KEYBOARD_SELECT -> SetupGuidancePresentation( + descriptionRes = R.string.access_keyboard_select_description, + stepResources = listOf( + R.string.access_keyboard_select_step_choose, + R.string.access_keyboard_select_step_return, + ), + ) +} + +internal fun AccessUnavailableReason.messageRes(): Int = when (this) { + AccessUnavailableReason.HARDWARE_ABSENT -> R.string.access_hardware_unavailable + AccessUnavailableReason.LOCATION_SETTINGS_CHANGE_UNAVAILABLE -> + R.string.access_location_settings_change_unavailable + AccessUnavailableReason.LOCATION_SETTINGS_CHECK_FAILED -> + R.string.access_location_settings_check_failed + AccessUnavailableReason.SYSTEM_HANDLER_MISSING -> R.string.access_system_screen_unavailable +} diff --git a/app/src/main/kotlin/cool/jacoblin/particeps/CollectionService.kt b/app/src/main/kotlin/cool/jacoblin/particeps/CollectionService.kt index 6631626..df88a61 100644 --- a/app/src/main/kotlin/cool/jacoblin/particeps/CollectionService.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/CollectionService.kt @@ -1,56 +1,186 @@ package cool.jacoblin.particeps import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.os.IBinder +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout class CollectionService : Service() { - override fun onCreate() { - super.onCreate() - getSystemService(NotificationManager::class.java).createNotificationChannel( - NotificationChannel( - CHANNEL_ID, - "Active research collection", - NotificationManager.IMPORTANCE_LOW, - ).apply { - description = "Shows when a consented research study is collecting data" - setShowBadge(false) - }, - ) - } + private var accessMonitor: Job? = null override fun onStartCommand( intent: Intent?, flags: Int, startId: Int, ): Int { - val title = intent?.getStringExtra(EXTRA_STUDY_TITLE) ?: "Research study" - val location = intent?.getBooleanExtra(EXTRA_LOCATION, false) == true - val types = ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE or - if (location) ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else 0 - startForeground(NOTIFICATION_ID, notification(title), types) - return START_STICKY + val startIntent = intent?.takeIf { + it.action == ACTION_START && + it.hasExtra(EXTRA_LOCATION) && + it.hasExtra(EXTRA_REQUEST_ID) + } + if (startIntent == null) { + stopSelf(startId) + return START_NOT_STICKY + } + val title = startIntent.getStringExtra(EXTRA_STUDY_TITLE)?.takeIf(String::isNotBlank) + if (title == null) { + stopSelf(startId) + return START_NOT_STICKY + } + val location = startIntent.getBooleanExtra(EXTRA_LOCATION, false) + val requestId = startIntent.getStringExtra(EXTRA_REQUEST_ID).orEmpty() + if (flags and START_FLAG_REDELIVERY != 0) { + try { + startForeground( + NOTIFICATION_ID, + notification(title, restoring = true), + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE, + ) + } catch (_: SecurityException) { + stopSelf(startId) + return START_NOT_STICKY + } catch (_: IllegalArgumentException) { + stopSelf(startId) + return START_NOT_STICKY + } + verifyRedeliveredService(startId) + return START_REDELIVER_INTENT + } + if (!CollectionServiceStartCoordinator.isPending(requestId)) { + // The caller was cancelled after asking Android to start the service. Satisfy the + // platform's foreground deadline with a neutral notification, then retire the orphan. + try { + startForeground( + NOTIFICATION_ID, + notification(title, restoring = true), + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE, + ) + stopForeground(STOP_FOREGROUND_REMOVE) + } catch (_: SecurityException) { + // There is no live caller and no safe collection state to recover. + } catch (_: IllegalArgumentException) { + // Manifest/type mismatch is a build defect; the orphan still must be stopped. + } + stopSelf(startId) + return START_NOT_STICKY + } + try { + val types = ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE or + if (location) ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else 0 + startForeground(NOTIFICATION_ID, notification(title, restoring = false), types) + if (!CollectionServiceStartCoordinator.succeed(requestId)) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf(startId) + return START_NOT_STICKY + } + startAccessMonitor() + } catch (failure: SecurityException) { + CollectionServiceStartCoordinator.fail(requestId, failure) + stopSelf(startId) + return START_NOT_STICKY + } catch (failure: IllegalArgumentException) { + CollectionServiceStartCoordinator.fail(requestId, failure) + stopSelf(startId) + return START_NOT_STICKY + } catch (failure: Throwable) { + CollectionServiceStartCoordinator.fail(requestId, failure) + stopSelf(startId) + throw failure + } + // The foreground-service type and participant-visible study title are security-relevant; + // ask Android to redeliver this exact app-authored intent after a process restart. + return START_REDELIVER_INTENT } override fun onBind(intent: Intent?): IBinder? = null - private fun notification(studyTitle: String): Notification { + override fun onDestroy() { + accessMonitor?.cancel() + accessMonitor = null + super.onDestroy() + } + + /** + * Android special access, notification channels, keyboard selection, and the device location + * toggle do not share one reliable change callback. Re-inspecting the closed access plan from + * the running foreground service keeps enforcement independent of the Activity lifecycle. + */ + private fun startAccessMonitor() { + if (accessMonitor?.isActive == true) return + val collectorApplication = application as CollectorApplication + accessMonitor = collectorApplication.applicationScope.launch { + while (isActive) { + delay(ACCESS_RECONCILIATION_INTERVAL_MILLIS) + collectorApplication.session.reconcileAccess() + } + } + } + + /** + * A redelivered intent belongs to a prior process and therefore has no live caller waiting for + * its request ID. Keep only a neutral restoration notification until the newly constructed + * session has revalidated durable state and current access, then issue a fresh acknowledged + * start with the exact foreground-service types or stop this stale service. + */ + private fun verifyRedeliveredService(startId: Int) { + val collectorApplication = application as CollectorApplication + accessMonitor?.cancel() + accessMonitor = collectorApplication.applicationScope.launch { + collectorApplication.session.snapshot.first { it.initialized } + val running = try { + collectorApplication.session.reconcileRedeliveredCollectionHost() + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + false + } + if (running) { + accessMonitor = null + startAccessMonitor() + } else { + stopSelfResult(startId) + } + } + } + + private fun notification(studyTitle: String, restoring: Boolean): Notification { val openApp = PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) - return Notification.Builder(this, CHANNEL_ID) + return Notification.Builder(this, ParticepsNotificationChannels.COLLECTION) .setSmallIcon(R.drawable.ic_app) - .setContentTitle("Research collection active") - .setContentText(studyTitle) + .setContentTitle( + getString( + if (restoring) { + R.string.collection_recovery_notification_title + } else { + R.string.collection_notification_title + }, + ), + ) + .setContentText( + if (restoring) { + getString(R.string.collection_recovery_notification_text) + } else { + studyTitle + }, + ) .setContentIntent(openApp) .setOngoing(true) .setCategory(Notification.CATEGORY_SERVICE) @@ -61,20 +191,35 @@ class CollectionService : Service() { private const val ACTION_START = "cool.jacoblin.particeps.START_COLLECTION" private const val EXTRA_STUDY_TITLE = "study_title" private const val EXTRA_LOCATION = "location" - private const val CHANNEL_ID = "active-research-collection" + private const val EXTRA_REQUEST_ID = "request_id" private const val NOTIFICATION_ID = 72 + private const val ACCESS_RECONCILIATION_INTERVAL_MILLIS = 25_000L + private const val START_CONFIRMATION_TIMEOUT_MILLIS = 5_000L - fun start( + suspend fun start( context: Context, studyTitle: String, location: Boolean, ) { - context.startForegroundService( - Intent(context, CollectionService::class.java) - .setAction(ACTION_START) - .putExtra(EXTRA_STUDY_TITLE, studyTitle) - .putExtra(EXTRA_LOCATION, location), - ) + val pending = CollectionServiceStartCoordinator.register() + try { + context.startForegroundService( + Intent(context, CollectionService::class.java) + .setAction(ACTION_START) + .putExtra(EXTRA_STUDY_TITLE, studyTitle) + .putExtra(EXTRA_LOCATION, location) + .putExtra(EXTRA_REQUEST_ID, pending.requestId), + ) + withTimeout(START_CONFIRMATION_TIMEOUT_MILLIS) { pending.confirmation.await() } + } catch (failure: TimeoutCancellationException) { + stop(context) + throw IllegalStateException("Foreground service did not acknowledge startup", failure) + } catch (failure: CancellationException) { + stop(context) + throw failure + } finally { + CollectionServiceStartCoordinator.remove(pending.requestId) + } } fun stop(context: Context) { @@ -82,3 +227,31 @@ class CollectionService : Service() { } } } + +private object CollectionServiceStartCoordinator { + data class Pending( + val requestId: String, + val confirmation: CompletableDeferred, + ) + + private val pending = ConcurrentHashMap>() + + fun register(): Pending { + val requestId = UUID.randomUUID().toString() + val confirmation = CompletableDeferred() + check(pending.putIfAbsent(requestId, confirmation) == null) + return Pending(requestId, confirmation) + } + + fun isPending(requestId: String): Boolean = pending.containsKey(requestId) + + fun succeed(requestId: String): Boolean = pending[requestId]?.complete(Unit) == true + + fun fail(requestId: String, failure: Throwable) { + pending[requestId]?.completeExceptionally(failure) + } + + fun remove(requestId: String) { + pending.remove(requestId)?.cancel() + } +} diff --git a/app/src/main/kotlin/cool/jacoblin/particeps/CollectorApplication.kt b/app/src/main/kotlin/cool/jacoblin/particeps/CollectorApplication.kt index 0019ce7..a68e60a 100644 --- a/app/src/main/kotlin/cool/jacoblin/particeps/CollectorApplication.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/CollectorApplication.kt @@ -34,6 +34,7 @@ import cool.jacoblin.particeps.core.storage.EncryptedExperimentStore import cool.jacoblin.particeps.platform.AndroidResearchClocks import cool.jacoblin.particeps.platform.AndroidStudyCollectionHost import cool.jacoblin.particeps.platform.AndroidStudyWorkScheduler +import cool.jacoblin.particeps.platform.AtomicSafetyPauseStore import cool.jacoblin.particeps.platform.FileUploadOutbox import cool.jacoblin.particeps.platform.InterventionDeliveryCoordinator import cool.jacoblin.particeps.platform.JoinArtifactDownloader @@ -56,7 +57,12 @@ class CollectorApplication : Application() { override fun onCreate() { super.onCreate() - accessManager = AccessManager(this, ResearchInputMethodService::class.java.name) + ParticepsNotificationChannels.ensureCreated(this) + accessManager = AccessManager( + this, + ResearchInputMethodService::class.java.name, + ParticepsNotificationChannels.idsByFeature, + ) joinArtifactDownloader = JoinArtifactDownloader(noBackupFilesDir.resolve("join-import")) val registry = CollectorRegistry( listOf( @@ -88,19 +94,20 @@ class CollectorApplication : Application() { storeFactory = StudyStoreFactory { experimentId, maximumLocalBytes -> EncryptedExperimentStore(this, experimentId, maximumLocalBytes) }, - runtimeFactory = ExperimentRuntimeFactory { configuration, store, availableAccess -> + runtimeFactory = ExperimentRuntimeFactory { configuration, store, safetyPauseWitness -> ExperimentRuntime( configuration, store, registry, AndroidResearchClocks(this, configuration.experimentId), applicationScope, - availableAccess, + safetyPauseWitness, ) }, collectorRegistry = registry, accessGateway = accessManager, collectionHost = AndroidStudyCollectionHost(this), + safetyPauseStore = AtomicSafetyPauseStore(this), workScheduler = workScheduler, exporter = StudyExporter { verified, metadata, events, destination -> ResearchExport.encrypt( @@ -128,11 +135,10 @@ class CollectorApplication : Application() { ) applicationScope.launch { session.initialize() - // The delivery chain is one-time work, so it has no platform-side repetition to fall - // back on. Re-establishing it here covers a link lost to a crash or a force stop. - session.snapshot.value.configuration?.let(workScheduler::reschedulePendingWork) InterventionDeliveryCoordinator.recoverStalePosting { - session.rescheduleInterventions(recoverStalePosting = true) + // Session recovery owns durable metadata, absolute deadline repair and intervention + // work. The Android adapter must never rebuild from configuration alone. + session.reconcileScheduledWork(recoverStalePosting = true) } } } diff --git a/app/src/main/kotlin/cool/jacoblin/particeps/CollectorDashboard.kt b/app/src/main/kotlin/cool/jacoblin/particeps/CollectorDashboard.kt index 8b875f2..d16d81b 100644 --- a/app/src/main/kotlin/cool/jacoblin/particeps/CollectorDashboard.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/CollectorDashboard.kt @@ -53,8 +53,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import cool.jacoblin.particeps.core.application.StudyAccessFeature +import cool.jacoblin.particeps.core.application.StudyAccessOwner +import cool.jacoblin.particeps.core.application.StudyAccessStatus import cool.jacoblin.particeps.core.collector.AccessKind -import cool.jacoblin.particeps.core.collector.AccessStatus +import cool.jacoblin.particeps.core.collector.AccessResolution +import cool.jacoblin.particeps.core.collector.SetupAction import cool.jacoblin.particeps.core.collector.CollectorStatus import cool.jacoblin.particeps.core.definition.StudyConfiguration import cool.jacoblin.particeps.core.definition.UploadConfiguration @@ -79,6 +83,11 @@ object UiTags { const val EXPORT = "export" const val EVENT_COUNT = "event_count" const val PAUSED_SINCE = "paused_since" + + fun accessItem(kind: AccessKind) = "access_item_${kind.name}" + fun accessOwners(kind: AccessKind) = "access_owners_${kind.name}" + fun accessInstructions(kind: AccessKind) = "access_instructions_${kind.name}" + fun accessAction(kind: AccessKind) = "access_action_${kind.name}" } data class StudyUiActions( @@ -88,7 +97,7 @@ data class StudyUiActions( val review: () -> Unit, val acceptConsent: () -> Unit, val completeAccess: () -> Unit, - val requestAccess: (AccessKind) -> Unit, + val requestAccess: (SetupAction) -> Unit, val start: () -> Unit, val pause: () -> Unit, val resume: () -> Unit, @@ -175,7 +184,12 @@ private fun Dashboard( // Re-entering CONSENT_PENDING starts at the data page again, so nobody lands on the checkbox // without the list of sources having been on screen. var page by remember(metadata?.state) { mutableStateOf(SetupStep.DATA) } + val requiredAccessMissing = study?.access?.any { it.requirement.required && !it.granted } == true val step = metadata?.state?.let { setupStep(it, page) } + val accessRemediation = requiredAccessMissing && when (metadata?.state) { + ExperimentState.READY, ExperimentState.RUNNING, ExperimentState.PAUSED -> true + else -> false + } Column( modifier = modifier @@ -200,27 +214,47 @@ private fun Dashboard( return@Column } - when (step) { - SetupStep.STUDY -> StudyPanel(study, actions, state.busy) - SetupStep.DATA -> DataPanel( + if (accessRemediation) { + AccessPanel( configuration = study.configuration, + checks = study.access, actions = actions, busy = state.busy, - onContinue = { page = SetupStep.CONSENT }, + completesInitialSetup = false, ) - SetupStep.CONSENT -> ConsentPanel(study, actions, state.busy) - SetupStep.ACCESS -> AccessPanel(study.access, actions, state.busy) - SetupStep.START -> StartPanel(actions, state.busy) - null -> CollectionPanel(study, actions, state.busy) + } else { + when (step) { + SetupStep.STUDY -> StudyPanel(study, actions, state.busy) + SetupStep.DATA -> DataPanel( + configuration = study.configuration, + actions = actions, + busy = state.busy, + onContinue = { page = SetupStep.CONSENT }, + ) + SetupStep.CONSENT -> ConsentPanel(study, actions, state.busy) + SetupStep.ACCESS -> AccessPanel( + configuration = study.configuration, + checks = study.access, + actions = actions, + busy = state.busy, + completesInitialSetup = true, + ) + SetupStep.START -> StartPanel(actions, state.busy) + null -> CollectionPanel(study, actions, state.busy) + } } } } -private fun setupStep(state: ExperimentState, page: SetupStep): SetupStep? = when (state) { +private fun setupStep( + state: ExperimentState, + page: SetupStep, +): SetupStep? = when (state) { ExperimentState.IMPORTED, ExperimentState.CONFIG_VERIFIED -> SetupStep.STUDY ExperimentState.CONSENT_PENDING -> page ExperimentState.ACCESS_SETUP -> SetupStep.ACCESS ExperimentState.READY -> SetupStep.START + ExperimentState.RUNNING, ExperimentState.PAUSED -> null else -> null } @@ -594,49 +628,175 @@ private fun UploadDisclosure(upload: UploadConfiguration?) { } } -/** Step 4. Each row is the control: tapping an outstanding item opens the screen that grants it. */ +/** + * Step 4. Each capability is one card, even when several collectors share it. + * + * Collector modules declare only a closed [AccessKind]. Android actions and participant-facing + * instructions remain app-owned, so neither a signed study nor a plugin can inject an intent or + * arbitrary setup copy. + */ @Composable -private fun AccessPanel(checks: List, actions: StudyUiActions, busy: Boolean) { +private fun AccessPanel( + configuration: StudyConfiguration, + checks: List, + actions: StudyUiActions, + busy: Boolean, + completesInitialSetup: Boolean, +) { val requiredReady = checks.none { it.requirement.required && !it.granted } - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { checks.forEach { check -> - val actionable = !check.granted && check.requirement.kind !in HARDWARE_ACCESS - Row( - modifier = Modifier - .fillMaxWidth() - .then( - if (actionable) { - Modifier.clickable { actions.requestAccess(check.requirement.kind) } - } else { - Modifier - }, + AccessCard(configuration, check, actions, busy) + } + if (completesInitialSetup) { + Spacer(Modifier.height(4.dp)) + Button( + onClick = actions.completeAccess, + enabled = requiredReady && !busy, + modifier = Modifier.fillMaxWidth().testTag(UiTags.ACCESS_COMPLETE), + ) { Text(stringResource(R.string.action_done)) } + } + WithdrawLink(actions, busy) + } +} + +@Composable +internal fun AccessCard( + configuration: StudyConfiguration, + check: StudyAccessStatus, + actions: StudyUiActions, + busy: Boolean, +) { + val kind = check.requirement.kind + Column( + modifier = Modifier + .fillMaxWidth() + .testTag(UiTags.accessItem(kind)) + .padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (check.granted) { + CheckMark(MaterialTheme.colorScheme.secondary, 16.dp) + } else { + PendingMark(blocking = check.requirement.required) + } + Text( + stringResource(kind.labelRes()), + modifier = Modifier.weight(1f), + fontWeight = FontWeight.SemiBold, + ) + if (!check.requirement.required) { + Text( + stringResource(R.string.data_optional), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + AccessOwners(configuration, kind, check.owners) + + if (!check.granted) { + check.guidance + ?.takeUnless { check.resolution is AccessResolution.Unavailable } + ?.presentation() + ?.let { guidance -> + Column( + modifier = Modifier.testTag(UiTags.accessInstructions(kind)), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + stringResource(guidance.descriptionRes), + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - .padding(vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (check.granted) { - CheckMark(MaterialTheme.colorScheme.secondary, 16.dp) - } else { - PendingMark(blocking = check.requirement.required) + Text(stringResource(R.string.access_manual_steps), fontWeight = FontWeight.SemiBold) + guidance.stepResources.forEachIndexed { index, stepRes -> + Text(stringResource(R.string.access_step_number, index + 1, guidanceStepText(stepRes))) + } + } + } + + when (val resolution = check.resolution) { + AccessResolution.Satisfied -> Unit + is AccessResolution.ActionRequired -> OutlinedButton( + onClick = { actions.requestAccess(resolution.action) }, + enabled = !busy, + modifier = Modifier.testTag(UiTags.accessAction(kind)), + ) { Text(stringResource(resolution.action.labelRes())) } + is AccessResolution.BlockedByPrerequisites -> resolution.missing + .sortedBy { it.name } + .forEach { prerequisite -> + Text( + stringResource( + R.string.access_complete_first, + stringResource(prerequisite.labelRes()), + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + is AccessResolution.Unavailable -> Text( + stringResource(resolution.reason.messageRes()), + color = MaterialTheme.colorScheme.error, + ) + } + } + HorizontalDivider() + } +} + +@Composable +private fun guidanceStepText(stepResource: Int): String = when (stepResource) { + R.string.access_background_location_step_choose_always -> stringResource( + stepResource, + LocalContext.current.packageManager.backgroundPermissionOptionLabel, + ) + else -> stringResource(stepResource) +} + +@Composable +private fun AccessOwners( + configuration: StudyConfiguration, + kind: AccessKind, + owners: Set, +) { + val collectorOrder = configuration.collectors.mapIndexed { index, collector -> collector.id to index }.toMap() + val collectors = configuration.collectors.associateBy { it.id } + val orderedOwners = owners.sortedBy { owner -> + when (owner) { + is StudyAccessOwner.Feature -> -1 + is StudyAccessOwner.Collector -> collectorOrder.getValue(owner.collectorId) + } + } + Column( + modifier = Modifier.testTag(UiTags.accessOwners(kind)), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + stringResource(R.string.access_used_by), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + orderedOwners.forEach { owner -> + val label = when (owner) { + is StudyAccessOwner.Collector -> collectors.getValue(owner.collectorId).summarize().name + is StudyAccessOwner.Feature -> when (owner.feature) { + StudyAccessFeature.STUDY_NOTIFICATIONS -> + stringResource(R.string.access_owner_study_notifications) } - Text(stringResource(check.requirement.kind.labelRes()), Modifier.weight(1f)) - if (!check.granted && !check.requirement.required) { + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.access_owner_item, label)) + if (!owner.required) { Text( stringResource(R.string.data_optional), color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - HorizontalDivider() } - Spacer(Modifier.height(12.dp)) - Button( - onClick = actions.completeAccess, - enabled = requiredReady && !busy, - modifier = Modifier.fillMaxWidth().testTag(UiTags.ACCESS_COMPLETE), - ) { Text(stringResource(R.string.action_done)) } - WithdrawLink(actions, busy) } } @@ -658,6 +818,12 @@ private fun CollectionPanel(study: StudyUiState.ActiveStudy, actions: StudyUiAct val state = study.metadata.state Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { CollectorGrid(study) + OptionalAccessRemediation( + configuration = study.configuration, + checks = study.access, + actions = actions, + busy = busy, + ) EventMeter(study) when (state) { ExperimentState.RUNNING -> CollectionControls( @@ -683,6 +849,25 @@ private fun CollectionPanel(study: StudyUiState.ActiveStudy, actions: StudyUiAct } } +@Composable +internal fun OptionalAccessRemediation( + configuration: StudyConfiguration, + checks: List, + actions: StudyUiActions, + busy: Boolean, +) { + val missing = checks.filter { !it.requirement.required && !it.granted } + if (missing.isEmpty()) return + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.optional_access_title), fontWeight = FontWeight.SemiBold) + Text( + stringResource(R.string.optional_access_body), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + missing.forEach { AccessCard(configuration, it, actions, busy) } + } +} + @Composable private fun WithdrawLink(actions: StudyUiActions, busy: Boolean) { TextButton( @@ -1024,26 +1209,6 @@ private fun ExperimentState.labelRes(): Int = when (this) { else -> R.string.state_running } -private fun AccessKind.labelRes(): Int = when (this) { - AccessKind.FINE_LOCATION -> R.string.access_fine_location - AccessKind.BACKGROUND_LOCATION -> R.string.access_background_location - AccessKind.NOTIFICATIONS -> R.string.access_notifications - AccessKind.USAGE_ACCESS -> R.string.access_usage_access - AccessKind.RESEARCH_KEYBOARD_ENABLED -> R.string.access_research_keyboard_enabled - AccessKind.RESEARCH_KEYBOARD_SELECTED -> R.string.access_research_keyboard_selected - AccessKind.ACCELEROMETER_HARDWARE -> R.string.access_accelerometer_hardware - AccessKind.GYROSCOPE_HARDWARE -> R.string.access_gyroscope_hardware - AccessKind.AMBIENT_LIGHT_HARDWARE -> R.string.access_ambient_light_hardware - AccessKind.PROXIMITY_HARDWARE -> R.string.access_proximity_hardware -} - -private val HARDWARE_ACCESS = setOf( - AccessKind.ACCELEROMETER_HARDWARE, - AccessKind.GYROSCOPE_HARDWARE, - AccessKind.AMBIENT_LIGHT_HARDWARE, - AccessKind.PROXIMITY_HARDWARE, -) - private val TERMINAL_STATES = setOf(ExperimentState.COMPLETED, ExperimentState.WITHDRAWN) private const val TICK_MILLIS = 30_000L diff --git a/app/src/main/kotlin/cool/jacoblin/particeps/DailyStatusWorker.kt b/app/src/main/kotlin/cool/jacoblin/particeps/DailyStatusWorker.kt index 2608d6a..ee2cf7c 100644 --- a/app/src/main/kotlin/cool/jacoblin/particeps/DailyStatusWorker.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/DailyStatusWorker.kt @@ -1,7 +1,6 @@ package cool.jacoblin.particeps import android.Manifest -import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context @@ -39,7 +38,8 @@ class DailyStatusWorker( val metadata = snapshot.runtime.metadata val state = metadata?.state if (snapshot.configuration == null || - (state != ExperimentState.RUNNING && state != ExperimentState.PAUSED) + (snapshot.safetyPauseStatus == null && + state != ExperimentState.RUNNING && state != ExperimentState.PAUSED) ) { // Finished, withdrawn, deleted, or never started. Nothing to remind anyone about, and // the periodic request outlives the study unless it retires itself here. @@ -53,8 +53,11 @@ class DailyStatusWorker( return Result.success() } - val text = when (state) { - ExperimentState.PAUSED -> { + val text = when { + snapshot.safetyPauseStatus != null -> { + applicationContext.getString(R.string.daily_paused_unknown) + } + state == ExperimentState.PAUSED -> { val pausedAt = metadata.transitions .lastOrNull { it.to == ExperimentState.PAUSED } ?.time @@ -77,20 +80,10 @@ class DailyStatusWorker( } val manager = applicationContext.getSystemService(NotificationManager::class.java) - manager.createNotificationChannel( - NotificationChannel( - CHANNEL_ID, - applicationContext.getString(R.string.daily_channel), - // Low: this arrives every day for as long as the study runs. Anything that makes a - // sound daily for a fortnight is a reason to uninstall the app, which would end the - // study far more effectively than a missed reminder. - NotificationManager.IMPORTANCE_LOW, - ), - ) manager.notify( NOTIFICATION_TAG, 0, - android.app.Notification.Builder(applicationContext, CHANNEL_ID) + android.app.Notification.Builder(applicationContext, ParticepsNotificationChannels.DAILY_STATUS) .setSmallIcon(android.R.drawable.ic_dialog_info) .setContentTitle(applicationContext.getString(R.string.app_name)) .setContentText(text) @@ -112,6 +105,5 @@ class DailyStatusWorker( companion object { /** One tag, so today's reminder replaces yesterday's rather than stacking up. */ const val NOTIFICATION_TAG = "daily-status" - private const val CHANNEL_ID = "research-daily-status-v1" } } diff --git a/app/src/main/kotlin/cool/jacoblin/particeps/ExperimentDeadlineWorker.kt b/app/src/main/kotlin/cool/jacoblin/particeps/ExperimentDeadlineWorker.kt index 7150098..de6f2e8 100644 --- a/app/src/main/kotlin/cool/jacoblin/particeps/ExperimentDeadlineWorker.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/ExperimentDeadlineWorker.kt @@ -3,10 +3,18 @@ package cool.jacoblin.particeps import android.content.Context import androidx.work.CoroutineWorker import androidx.work.WorkerParameters -import cool.jacoblin.particeps.core.model.ExperimentState -import cool.jacoblin.particeps.core.runtime.CommandResult +import cool.jacoblin.particeps.core.application.DurationCompletionResult import kotlinx.coroutines.flow.first +internal enum class DeadlineWorkDisposition { SUCCESS, RETRY, FAILURE } + +internal fun DurationCompletionResult.deadlineWorkDisposition(): DeadlineWorkDisposition = when (this) { + DurationCompletionResult.Completed, + DurationCompletionResult.Inactive -> DeadlineWorkDisposition.SUCCESS + is DurationCompletionResult.NotDue -> DeadlineWorkDisposition.RETRY + is DurationCompletionResult.Failed -> DeadlineWorkDisposition.FAILURE +} + class ExperimentDeadlineWorker( context: Context, parameters: WorkerParameters, @@ -16,14 +24,10 @@ class ExperimentDeadlineWorker( val session = (applicationContext as CollectorApplication).session val snapshot = session.snapshot.first { it.initialized } if (snapshot.configuration?.experimentId != expectedExperimentId) return Result.success() - return when (snapshot.runtime.metadata?.state) { - ExperimentState.RUNNING, - ExperimentState.PAUSED -> if (session.completeAfterDuration() == CommandResult.Success) { - Result.success() - } else { - Result.failure() - } - else -> Result.success() + return when (session.completeAfterDurationIfDue().deadlineWorkDisposition()) { + DeadlineWorkDisposition.SUCCESS -> Result.success() + DeadlineWorkDisposition.RETRY -> Result.retry() + DeadlineWorkDisposition.FAILURE -> Result.failure() } } diff --git a/app/src/main/kotlin/cool/jacoblin/particeps/MainActivity.kt b/app/src/main/kotlin/cool/jacoblin/particeps/MainActivity.kt index 7981038..3843a42 100644 --- a/app/src/main/kotlin/cool/jacoblin/particeps/MainActivity.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/MainActivity.kt @@ -1,6 +1,7 @@ package cool.jacoblin.particeps import android.Manifest +import android.content.ActivityNotFoundException import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity @@ -11,7 +12,7 @@ import androidx.activity.viewModels import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import cool.jacoblin.particeps.platform.InterventionWorker -import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.SetupAction import cool.jacoblin.particeps.core.protocol.JoinLink import cool.jacoblin.particeps.core.protocol.SignedConfigurationCodec import java.time.Instant @@ -44,7 +45,24 @@ class MainActivity : ComponentActivity() { private val permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), - ) { viewModel.refreshAccess() } + ) { grants -> + val result = when { + Manifest.permission.POST_NOTIFICATIONS in grants -> + SetupAction.RuntimePermission.NOTIFICATIONS to Manifest.permission.POST_NOTIFICATIONS + Manifest.permission.ACCESS_FINE_LOCATION in grants -> + SetupAction.RuntimePermission.FOREGROUND_LOCATION to Manifest.permission.ACCESS_FINE_LOCATION + else -> null + } + result?.let { (action, permission) -> + val granted = grants.getValue(permission) + collectorApplication.accessManager.recordRuntimePermissionResult( + action = action, + granted = granted, + canRequestAgain = granted || shouldShowRequestPermissionRationale(permission), + ) + } + viewModel.refreshAccess() + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -81,6 +99,14 @@ class MainActivity : ComponentActivity() { viewModel.refreshAccess() } + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + // The system input-method picker is a window, not another Activity, so onResume is not a + // reliable completion signal. Regaining focus is the first authoritative point at which + // the selected keyboard can be re-inspected. + if (hasFocus) viewModel.refreshAccess() + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) @@ -128,25 +154,33 @@ class MainActivity : ComponentActivity() { { viewModel.importSignedConfiguration { load(resources) } } } - private fun requestAccess(kind: AccessKind) { - when (kind) { - AccessKind.FINE_LOCATION -> permissionLauncher.launch( - arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION), - ) - AccessKind.BACKGROUND_LOCATION -> permissionLauncher.launch( - arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION), - ) - AccessKind.NOTIFICATIONS -> permissionLauncher.launch( - arrayOf(Manifest.permission.POST_NOTIFICATIONS), - ) - AccessKind.RESEARCH_KEYBOARD_SELECTED -> collectorApplication.accessManager.showInputMethodPicker() - AccessKind.USAGE_ACCESS, - AccessKind.RESEARCH_KEYBOARD_ENABLED -> collectorApplication.accessManager.settingsIntent(kind)?.let(::startActivity) - ?: viewModel.reportMessage("ACCESS_SETTINGS_UNAVAILABLE") - AccessKind.ACCELEROMETER_HARDWARE, - AccessKind.GYROSCOPE_HARDWARE, - AccessKind.AMBIENT_LIGHT_HARDWARE, - AccessKind.PROXIMITY_HARDWARE -> Unit + private fun requestAccess(action: SetupAction) { + when (action) { + is SetupAction.RuntimePermission -> when (action) { + SetupAction.RuntimePermission.FOREGROUND_LOCATION -> permissionLauncher.launch( + arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION), + ) + SetupAction.RuntimePermission.NOTIFICATIONS -> permissionLauncher.launch( + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + ) + } + is SetupAction.SystemSettings -> { + val settingsIntent = collectorApplication.accessManager.settingsIntent(action) + if (settingsIntent == null) { + viewModel.reportMessage("ACCESS_SETTINGS_UNAVAILABLE") + return + } + try { + startActivity(settingsIntent) + } catch (_: ActivityNotFoundException) { + viewModel.reportMessage("ACCESS_SETTINGS_UNAVAILABLE") + viewModel.refreshAccess() + } catch (_: SecurityException) { + viewModel.reportMessage("ACCESS_SETTINGS_UNAVAILABLE") + viewModel.refreshAccess() + } + } + SetupAction.ShowInputMethodPicker -> collectorApplication.accessManager.showInputMethodPicker() } } diff --git a/app/src/main/kotlin/cool/jacoblin/particeps/ParticepsNotificationChannels.kt b/app/src/main/kotlin/cool/jacoblin/particeps/ParticepsNotificationChannels.kt new file mode 100644 index 0000000..36de98c --- /dev/null +++ b/app/src/main/kotlin/cool/jacoblin/particeps/ParticepsNotificationChannels.kt @@ -0,0 +1,48 @@ +package cool.jacoblin.particeps + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import cool.jacoblin.particeps.core.collector.NotificationAccessFeature + +internal object ParticepsNotificationChannels { + const val COLLECTION = "active-research-collection" + const val DAILY_STATUS = "research-daily-status-v1" + const val INTERVENTIONS = "research-interventions-v1" + + val idsByFeature: Map = mapOf( + NotificationAccessFeature.COLLECTION to COLLECTION, + NotificationAccessFeature.DAILY_STATUS to DAILY_STATUS, + NotificationAccessFeature.INTERVENTIONS to INTERVENTIONS, + ) + + fun ensureCreated(context: Context) { + val manager = context.getSystemService(NotificationManager::class.java) + manager.createNotificationChannels( + listOf( + NotificationChannel( + COLLECTION, + context.getString(R.string.collection_channel), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = context.getString(R.string.collection_channel_description) + setShowBadge(false) + }, + NotificationChannel( + DAILY_STATUS, + context.getString(R.string.daily_channel), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = context.getString(R.string.daily_channel_description) + }, + NotificationChannel( + INTERVENTIONS, + context.getString(R.string.intervention_channel), + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = context.getString(R.string.intervention_channel_description) + }, + ), + ) + } +} diff --git a/app/src/main/kotlin/cool/jacoblin/particeps/SafetyPauseWorker.kt b/app/src/main/kotlin/cool/jacoblin/particeps/SafetyPauseWorker.kt new file mode 100644 index 0000000..ffd1ea3 --- /dev/null +++ b/app/src/main/kotlin/cool/jacoblin/particeps/SafetyPauseWorker.kt @@ -0,0 +1,79 @@ +package cool.jacoblin.particeps + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import cool.jacoblin.particeps.core.application.StudySessionSnapshot +import cool.jacoblin.particeps.core.model.SafetyPauseReason +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first + +internal sealed interface SafetyPauseWorkerDecision { + data object Retry : SafetyPauseWorkerDecision + data object Complete : SafetyPauseWorkerDecision + data class Attempt( + val experimentId: String, + val reason: SafetyPauseReason, + ) : SafetyPauseWorkerDecision +} + +internal fun safetyPauseWorkerDecision( + encodedExperimentId: String?, + encodedReason: String?, + snapshot: StudySessionSnapshot, +): SafetyPauseWorkerDecision { + val experimentId = encodedExperimentId + ?.takeIf(String::isNotBlank) + ?: return SafetyPauseWorkerDecision.Retry + val reason = encodedReason + ?.let { encoded -> SafetyPauseReason.entries.singleOrNull { it.name == encoded } } + ?: return SafetyPauseWorkerDecision.Retry + val configuration = snapshot.configuration + if (configuration == null) { + return if (snapshot.safetyPauseStatus != null || snapshot.recoveryBlocked) { + SafetyPauseWorkerDecision.Retry + } else { + SafetyPauseWorkerDecision.Complete + } + } + return if (configuration.experimentId == experimentId) { + SafetyPauseWorkerDecision.Attempt(experimentId, reason) + } else { + SafetyPauseWorkerDecision.Complete + } +} + +/** Independently retries one typed fail-closed PAUSED boundary and its cleanup. */ +class SafetyPauseWorker( + context: Context, + parameters: WorkerParameters, +) : CoroutineWorker(context, parameters) { + override suspend fun doWork(): Result { + val application = applicationContext as CollectorApplication + val snapshot = application.session.snapshot.first { it.initialized } + val decision = safetyPauseWorkerDecision( + encodedExperimentId = inputData.getString(KEY_EXPERIMENT_ID), + encodedReason = inputData.getString(KEY_REASON), + snapshot = snapshot, + ) + return when (decision) { + SafetyPauseWorkerDecision.Retry -> Result.retry() + SafetyPauseWorkerDecision.Complete -> Result.success() + is SafetyPauseWorkerDecision.Attempt -> try { + if (application.session.retrySafetyPause(decision.experimentId, decision.reason)) { + Result.success() + } else { + Result.retry() + } + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + Result.retry() + } + } + } + + companion object { + const val KEY_EXPERIMENT_ID = "experiment_id" + const val KEY_REASON = "reason" + } +} diff --git a/app/src/main/kotlin/cool/jacoblin/particeps/BootRecoveryReceiver.kt b/app/src/main/kotlin/cool/jacoblin/particeps/ScheduledWorkRecoveryReceiver.kt similarity index 67% rename from app/src/main/kotlin/cool/jacoblin/particeps/BootRecoveryReceiver.kt rename to app/src/main/kotlin/cool/jacoblin/particeps/ScheduledWorkRecoveryReceiver.kt index aa939b5..dc6a41d 100644 --- a/app/src/main/kotlin/cool/jacoblin/particeps/BootRecoveryReceiver.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/ScheduledWorkRecoveryReceiver.kt @@ -7,7 +7,8 @@ import cool.jacoblin.particeps.platform.InterventionDeliveryCoordinator import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -class BootRecoveryReceiver : BroadcastReceiver() { +/** Reconciles durable work after boot, clock, or time-zone changes. */ +class ScheduledWorkRecoveryReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action !in RECOVERY_ACTIONS) return val pending = goAsync() @@ -17,10 +18,10 @@ class BootRecoveryReceiver : BroadcastReceiver() { application.session.snapshot.first { it.initialized } if (intent.action == Intent.ACTION_BOOT_COMPLETED) { InterventionDeliveryCoordinator.recoverStalePosting { - application.session.rescheduleInterventions(recoverStalePosting = true) + application.session.reconcileScheduledWork(recoverStalePosting = true) } } else { - application.session.rescheduleInterventions() + application.session.reconcileScheduledWork() } } finally { pending.finish() @@ -29,6 +30,10 @@ class BootRecoveryReceiver : BroadcastReceiver() { } private companion object { - val RECOVERY_ACTIONS = setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_TIME_CHANGED, Intent.ACTION_TIMEZONE_CHANGED) + val RECOVERY_ACTIONS = setOf( + Intent.ACTION_BOOT_COMPLETED, + Intent.ACTION_TIME_CHANGED, + Intent.ACTION_TIMEZONE_CHANGED, + ) } } diff --git a/app/src/main/kotlin/cool/jacoblin/particeps/StudyViewModel.kt b/app/src/main/kotlin/cool/jacoblin/particeps/StudyViewModel.kt index 3d84bf7..e354d75 100644 --- a/app/src/main/kotlin/cool/jacoblin/particeps/StudyViewModel.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/StudyViewModel.kt @@ -3,9 +3,9 @@ package cool.jacoblin.particeps import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import cool.jacoblin.particeps.core.application.StudyAccessStatus import cool.jacoblin.particeps.core.application.StudySessionManager import cool.jacoblin.particeps.core.application.UploadStatus -import cool.jacoblin.particeps.core.collector.AccessStatus import cool.jacoblin.particeps.core.collector.CollectorHealth import cool.jacoblin.particeps.core.definition.StudyConfiguration import cool.jacoblin.particeps.core.export.ExportReceipt @@ -41,7 +41,7 @@ sealed interface StudyUiState { data class ActiveStudy( val configuration: StudyConfiguration, val metadata: StudyMetadata, - val access: List, + val access: List, val collectorHealth: Map, val lastExport: ExportReceipt?, val upload: UploadStatus?, @@ -111,7 +111,9 @@ class StudyViewModel( localMessage.value = "LOCAL_DATA_DELETED" } - fun refreshAccess() = session.refreshAccess() + fun refreshAccess() = operation(INCIDENT_ACCESS_INSPECTION_FAILED) { + session.reconcileAccess() + } fun reportMessage(code: String) { localMessage.value = code @@ -156,5 +158,6 @@ class StudyViewModel( const val INCIDENT_EXPORT_FAILED = "EXPORT_FAILED" const val INCIDENT_DELETE_FAILED = "LOCAL_DATA_DELETE_FAILED" const val INCIDENT_COMMAND_FAILED = "COMMAND_FAILED" + const val INCIDENT_ACCESS_INSPECTION_FAILED = "ACCESS_INSPECTION_FAILED" } } diff --git a/app/src/main/kotlin/cool/jacoblin/particeps/UploadWorker.kt b/app/src/main/kotlin/cool/jacoblin/particeps/UploadWorker.kt index 78622d0..988f511 100644 --- a/app/src/main/kotlin/cool/jacoblin/particeps/UploadWorker.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/UploadWorker.kt @@ -6,7 +6,9 @@ import androidx.work.ExistingWorkPolicy import androidx.work.WorkerParameters import cool.jacoblin.particeps.core.application.UploadAttemptResult import cool.jacoblin.particeps.platform.AndroidStudyWorkScheduler +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext /** * Periodically delivers undelivered events to the study's endpoint. @@ -48,12 +50,16 @@ class UploadWorker( // Once the backlog is gone there is nothing left to wake up for, so the chain simply is // not renewed. if (!session.uploadDrained()) { - AndroidStudyWorkScheduler(applicationContext).scheduleUpload( - expectedExperimentId, - expectedConfigurationId, - upload, - ExistingWorkPolicy.REPLACE, - ) + // REPLACE cancels this worker. Keep the short WorkManager transaction alive until its + // replacement is durably acknowledged; no upload/network work runs in this context. + withContext(NonCancellable) { + AndroidStudyWorkScheduler(applicationContext).scheduleUpload( + expectedExperimentId, + expectedConfigurationId, + upload, + ExistingWorkPolicy.REPLACE, + ) + } } return Result.success() } diff --git a/app/src/main/kotlin/cool/jacoblin/particeps/platform/AndroidStudyPlatform.kt b/app/src/main/kotlin/cool/jacoblin/particeps/platform/AndroidStudyPlatform.kt index a5886f7..7dbe860 100644 --- a/app/src/main/kotlin/cool/jacoblin/particeps/platform/AndroidStudyPlatform.kt +++ b/app/src/main/kotlin/cool/jacoblin/particeps/platform/AndroidStudyPlatform.kt @@ -1,7 +1,6 @@ package cool.jacoblin.particeps.platform import android.Manifest -import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context @@ -15,35 +14,50 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.Operation import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager +import androidx.work.await import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import cool.jacoblin.particeps.CollectionService import cool.jacoblin.particeps.DailyStatusWorker import cool.jacoblin.particeps.ExperimentDeadlineWorker import cool.jacoblin.particeps.MainActivity +import cool.jacoblin.particeps.ParticepsNotificationChannels import cool.jacoblin.particeps.R +import cool.jacoblin.particeps.SafetyPauseWorker import cool.jacoblin.particeps.SurveyActivity import cool.jacoblin.particeps.UploadWorker import cool.jacoblin.particeps.core.application.StudyCollectionHost import cool.jacoblin.particeps.core.application.StudyWorkScheduler +import cool.jacoblin.particeps.core.application.participantStartedAt +import cool.jacoblin.particeps.core.application.studyLifetime import cool.jacoblin.particeps.core.definition.StudyConfiguration import cool.jacoblin.particeps.core.definition.SurveyAction import cool.jacoblin.particeps.core.definition.UploadConfiguration import cool.jacoblin.particeps.core.model.InterventionOccurrence +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.SafetyPauseReason +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.TransitionReason import cool.jacoblin.particeps.core.runtime.OccurrenceClaimResult import cool.jacoblin.particeps.core.runtime.OccurrenceExpiryResult +import java.security.MessageDigest import java.util.concurrent.TimeUnit import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext class AndroidStudyCollectionHost( private val context: Context, ) : StudyCollectionHost { - override fun start(studyTitle: String, usesLocation: Boolean) { + override suspend fun start(studyTitle: String, usesLocation: Boolean) { CollectionService.start(context, studyTitle, usesLocation) retractStaleDailyReminder() } @@ -75,29 +89,44 @@ class AndroidStudyWorkScheduler( private val workManager = WorkManager.getInstance(context.applicationContext) private val notificationManager = context.getSystemService(NotificationManager::class.java) - override fun schedule(configuration: StudyConfiguration) { - val deadline = OneTimeWorkRequestBuilder() - .setInitialDelay(configuration.durationHours.toLong(), TimeUnit.HOURS) - .setInputData( - Data.Builder() - .putString(ExperimentDeadlineWorker.KEY_EXPERIMENT_ID, configuration.experimentId) - .build(), - ) - .build() - workManager.enqueueUniqueWork( - deadlineWorkName(configuration.experimentId), - ExistingWorkPolicy.REPLACE, - deadline, - ) - configuration.upload?.let { - scheduleUpload( - configuration.experimentId, - configuration.configurationId, - it, - ExistingWorkPolicy.REPLACE, - ) + override suspend fun ensureCollectionWork( + configuration: StudyConfiguration, + metadata: StudyMetadata, + observedAt: ResearchTime, + ) { + val plan = collectionWorkPlan(configuration, metadata, observedAt) + val mutations = mutableListOf<() -> Operation>() + plan.deadlineDelayMillis?.let { deadlineDelayMillis -> + val deadline = OneTimeWorkRequestBuilder() + .setInitialDelay(deadlineDelayMillis, TimeUnit.MILLISECONDS) + .setInputData( + Data.Builder() + .putString(ExperimentDeadlineWorker.KEY_EXPERIMENT_ID, configuration.experimentId) + .build(), + ) + .build() + mutations += { + workManager.enqueueUniqueWork( + deadlineWorkName(configuration.experimentId), + plan.deadlinePolicy, + deadline, + ) + } + } + if (plan.scheduleDailyStatus) { + mutations += ::scheduleDailyStatus } - scheduleDailyStatus() + if (plan.scheduleUpload) configuration.upload?.let { upload -> + mutations += { + uploadOperation( + configuration.experimentId, + configuration.configurationId, + upload, + ExistingWorkPolicy.KEEP, + ) + } + } + awaitWorkMutations(mutations) } /** @@ -114,7 +143,7 @@ class AndroidStudyWorkScheduler( * already-posted notification is a separate matter: pausing retracts it, because it states a * state that has just stopped being true. See [AndroidStudyCollectionHost]. */ - private fun scheduleDailyStatus() { + private fun scheduleDailyStatus(): Operation = workManager.enqueueUniquePeriodicWork( DAILY_STATUS_WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, @@ -122,37 +151,41 @@ class AndroidStudyWorkScheduler( .setInitialDelay(1, TimeUnit.DAYS) .build(), ) - } - override fun replaceInterventionWork( + override suspend fun replaceInterventionWork( configuration: StudyConfiguration, deliveries: List, expiries: List, ) { - workManager.cancelAllWorkByTag(InterventionWorkIdentity.deliveryTag(configuration.experimentId)) - workManager.cancelAllWorkByTag(InterventionWorkIdentity.expiryTag(configuration.experimentId)) - deliveries.forEach { enqueueDelivery(configuration, it, ExistingWorkPolicy.REPLACE) } - expiries.forEach { enqueueExpiry(configuration, it, ExistingWorkPolicy.REPLACE) } - } - - override fun enqueueOccurrence(configuration: StudyConfiguration, occurrence: InterventionOccurrence) { - enqueueOccurrence(configuration, occurrence, ExistingWorkPolicy.KEEP) + awaitWorkMutations( + listOf( + { workManager.cancelAllWorkByTag(InterventionWorkIdentity.deliveryTag(configuration.experimentId)) }, + { workManager.cancelAllWorkByTag(InterventionWorkIdentity.expiryTag(configuration.experimentId)) }, + ), + ) + awaitWorkMutations( + deliveries.map { occurrence -> + { enqueueDelivery(configuration, occurrence, ExistingWorkPolicy.REPLACE) } + } + expiries.map { occurrence -> + { enqueueExpiry(configuration, occurrence, ExistingWorkPolicy.REPLACE) } + }, + ) } - private fun enqueueOccurrence( - configuration: StudyConfiguration, - occurrence: InterventionOccurrence, - policy: ExistingWorkPolicy, - ) { - enqueueDelivery(configuration, occurrence, policy) - enqueueExpiry(configuration, occurrence, policy) + override suspend fun enqueueOccurrence(configuration: StudyConfiguration, occurrence: InterventionOccurrence) { + awaitWorkMutations( + listOf( + { enqueueDelivery(configuration, occurrence, ExistingWorkPolicy.KEEP) }, + { enqueueExpiry(configuration, occurrence, ExistingWorkPolicy.KEEP) }, + ), + ) } private fun enqueueDelivery( configuration: StudyConfiguration, occurrence: InterventionOccurrence, policy: ExistingWorkPolicy, - ) { + ): Operation { val now = System.currentTimeMillis() val delay = (occurrence.scheduledFor.wallTimeUtcMillis - now).coerceAtLeast(0) val request = OneTimeWorkRequestBuilder() @@ -160,7 +193,7 @@ class AndroidStudyWorkScheduler( .setInputData(Data.Builder().putString(InterventionWorker.KEY_OCCURRENCE_ID, occurrence.occurrenceId).build()) .addTag(InterventionWorkIdentity.deliveryTag(configuration.experimentId)) .build() - workManager.enqueueUniqueWork( + return workManager.enqueueUniqueWork( InterventionWorkIdentity.deliveryName(configuration.experimentId, occurrence.occurrenceId), policy, request, @@ -171,14 +204,14 @@ class AndroidStudyWorkScheduler( configuration: StudyConfiguration, occurrence: InterventionOccurrence, policy: ExistingWorkPolicy, - ) { + ): Operation { val now = System.currentTimeMillis() val expiry = OneTimeWorkRequestBuilder() .setInitialDelay((occurrence.expiresAtUtcMillis - now).coerceAtLeast(0), TimeUnit.MILLISECONDS) .setInputData(Data.Builder().putString(InterventionWorker.KEY_OCCURRENCE_ID, occurrence.occurrenceId).build()) .addTag(InterventionWorkIdentity.expiryTag(configuration.experimentId)) .build() - workManager.enqueueUniqueWork( + return workManager.enqueueUniqueWork( InterventionWorkIdentity.expiryName(configuration.experimentId, occurrence.occurrenceId), policy, expiry, @@ -193,14 +226,24 @@ class AndroidStudyWorkScheduler( * self-renewing one-time chain honours whatever the signed configuration asked for. * * The cost of the chain is that it has no platform-side repetition to fall back on, so - * [reschedulePendingWork] re-establishes it whenever a session initialises. + * [ensureCollectionWork] re-establishes it on Start, Resume, same-boot reconciliation, and + * terminal upload-tail repair. */ - fun scheduleUpload( + internal suspend fun scheduleUpload( experimentId: String, configurationId: String, upload: UploadConfiguration, policy: ExistingWorkPolicy, ) { + awaitWorkMutations(listOf({ uploadOperation(experimentId, configurationId, upload, policy) })) + } + + private fun uploadOperation( + experimentId: String, + configurationId: String, + upload: UploadConfiguration, + policy: ExistingWorkPolicy, + ): Operation { val constraints = Constraints.Builder() // Default to Wi-Fi. Uploading a study over a participant's mobile data is a cost they // did not agree to unless the signed configuration says so. @@ -221,49 +264,88 @@ class AndroidStudyWorkScheduler( ) .addTag(uploadTag(experimentId)) .build() - workManager.enqueueUniqueWork(uploadWorkName(experimentId, configurationId), policy, request) - } - - /** - * Re-establishes the delivery chain after a process restart. KEEP, so a link already waiting - * is left alone rather than having its delay reset on every app start. - */ - fun reschedulePendingWork(configuration: StudyConfiguration) { - // Also covers a study that was already under way before the reminder existed, and one - // whose periodic work a force stop cleared. - scheduleDailyStatus() - configuration.upload?.let { - scheduleUpload( - configuration.experimentId, - configuration.configurationId, - it, - ExistingWorkPolicy.KEEP, - ) - } + return workManager.enqueueUniqueWork(uploadWorkName(experimentId, configurationId), policy, request) } - override fun cancelInterventionWork(experimentId: String, occurrenceIds: Set) { - workManager.cancelAllWorkByTag(InterventionWorkIdentity.deliveryTag(experimentId)) - workManager.cancelAllWorkByTag(InterventionWorkIdentity.expiryTag(experimentId)) - cancelInterventionNotifications(occurrenceIds) + override suspend fun cancelInterventionWork(experimentId: String, occurrenceIds: Set) { + awaitCleanupMutations( + notificationCleanup = { cancelInterventionNotifications(occurrenceIds) }, + mutations = listOf( + { workManager.cancelAllWorkByTag(InterventionWorkIdentity.deliveryTag(experimentId)) }, + { workManager.cancelAllWorkByTag(InterventionWorkIdentity.expiryTag(experimentId)) }, + ), + ) } override fun cancelInterventionNotifications(occurrenceIds: Set) { occurrenceIds.forEach { notificationManager.cancel(it, 0) } } - override fun cancelCollectionWork(experimentId: String, occurrenceIds: Set) { - cancelInterventionWork(experimentId, occurrenceIds) - workManager.cancelUniqueWork(deadlineWorkName(experimentId)) - // Finished or withdrawn: the reminder has nothing left to remind anyone of, and today's - // notification would otherwise sit in the shade after the study it describes has ended. - workManager.cancelUniqueWork(DAILY_STATUS_WORK_NAME) - notificationManager.cancel(DailyStatusWorker.NOTIFICATION_TAG, 0) + override suspend fun scheduleSafetyPauseRetry(experimentId: String, reason: SafetyPauseReason) { + val request = OneTimeWorkRequestBuilder() + .also { builder -> + SafetyPauseWorkIdentity.tags(experimentId, reason).forEach(builder::addTag) + } + .setInputData( + Data.Builder() + .putString(SafetyPauseWorker.KEY_EXPERIMENT_ID, experimentId) + .putString(SafetyPauseWorker.KEY_REASON, reason.name) + .build(), + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS) + .build() + awaitWorkMutations( + listOf({ + workManager.enqueueUniqueWork( + SafetyPauseWorkIdentity.workName(experimentId, reason), + ExistingWorkPolicy.REPLACE, + request, + ) + }), + ) } - override fun cancel(experimentId: String) { - cancelCollectionWork(experimentId, emptySet()) - workManager.cancelAllWorkByTag(uploadTag(experimentId)) + override suspend fun pendingSafetyPauseReason(experimentId: String): SafetyPauseReason? = + withContext(Dispatchers.IO) { + val active = workManager.getWorkInfosByTag(SafetyPauseWorkIdentity.COMMON_TAG).get() + .filterNot { it.state.isFinished } + SafetyPauseWorkIdentity.activeReason(experimentId, active.map { it.tags }) + } + + override suspend fun cancelSafetyPauseRetry() { + withContext(NonCancellable) { + awaitWorkMutations(listOf({ workManager.cancelAllWorkByTag(SafetyPauseWorkIdentity.COMMON_TAG) })) + } + } + + override suspend fun cancelCollectionWork(experimentId: String, occurrenceIds: Set) { + awaitCleanupMutations( + notificationCleanup = { + cancelInterventionNotifications(occurrenceIds) + // Finished or withdrawn: the reminder has nothing left to remind anyone of, and + // today's notification must not outlive the study it describes. + notificationManager.cancel(DailyStatusWorker.NOTIFICATION_TAG, 0) + }, + mutations = listOf( + { workManager.cancelAllWorkByTag(InterventionWorkIdentity.deliveryTag(experimentId)) }, + { workManager.cancelAllWorkByTag(InterventionWorkIdentity.expiryTag(experimentId)) }, + { workManager.cancelUniqueWork(deadlineWorkName(experimentId)) }, + { workManager.cancelUniqueWork(DAILY_STATUS_WORK_NAME) }, + ), + ) + } + + override suspend fun cancel(experimentId: String) { + awaitCleanupMutations( + notificationCleanup = { notificationManager.cancel(DailyStatusWorker.NOTIFICATION_TAG, 0) }, + mutations = listOf( + { workManager.cancelAllWorkByTag(InterventionWorkIdentity.deliveryTag(experimentId)) }, + { workManager.cancelAllWorkByTag(InterventionWorkIdentity.expiryTag(experimentId)) }, + { workManager.cancelUniqueWork(deadlineWorkName(experimentId)) }, + { workManager.cancelUniqueWork(DAILY_STATUS_WORK_NAME) }, + { workManager.cancelAllWorkByTag(uploadTag(experimentId)) }, + ), + ) } private fun deadlineWorkName(experimentId: String) = "particeps-deadline-$experimentId" @@ -275,6 +357,152 @@ class AndroidStudyWorkScheduler( } } +internal data class CollectionWorkPlan( + val deadlineDelayMillis: Long?, + val deadlinePolicy: ExistingWorkPolicy, + val scheduleDailyStatus: Boolean, + val scheduleUpload: Boolean, +) + +/** Pure, auditable policy used by every start, resume and recovery scheduling acknowledgement. */ +internal fun collectionWorkPlan( + configuration: StudyConfiguration, + metadata: StudyMetadata, + observedAt: ResearchTime, +): CollectionWorkPlan { + require(metadata.experimentId == configuration.experimentId) { "Experiment ID mismatch" } + require(metadata.configurationId == configuration.configurationId) { "Configuration ID mismatch" } + val started = metadata.state in STARTED_STUDY_STATES + if (!started) { + require(metadata.transitions.none { it.reason == TransitionReason.PARTICIPANT_STARTED }) { + "Pre-start study contains a participant start" + } + return CollectionWorkPlan( + deadlineDelayMillis = null, + deadlinePolicy = ExistingWorkPolicy.REPLACE, + scheduleDailyStatus = false, + scheduleUpload = false, + ) + } + participantStartedAt(metadata) + val active = metadata.state in ACTIVE_STUDY_STATES + if (!active) { + return CollectionWorkPlan( + deadlineDelayMillis = null, + deadlinePolicy = ExistingWorkPolicy.REPLACE, + scheduleDailyStatus = false, + scheduleUpload = configuration.upload != null, + ) + } + val lifetime = studyLifetime(configuration, metadata, observedAt) + return CollectionWorkPlan( + // REPLACE is intentional: same-boot TIME_CHANGED/process recovery and rc5's reset deadline + // must be corrected from the immutable participant-start boundary on every acknowledged + // ensure. Cross-boot active repair is rejected by studyLifetime before reaching this plan. + deadlineDelayMillis = lifetime.remainingMillis.takeIf { active }, + deadlinePolicy = ExistingWorkPolicy.REPLACE, + scheduleDailyStatus = active, + scheduleUpload = configuration.upload != null, + ) +} + +private val ACTIVE_STUDY_STATES = setOf(ExperimentState.RUNNING, ExperimentState.PAUSED) +private val STARTED_STUDY_STATES = ACTIVE_STUDY_STATES + setOf( + ExperimentState.COMPLETED, + ExperimentState.WITHDRAWN, +) + +/** Does not report a retry boundary as durable until WorkManager commits its transaction. */ +internal suspend fun awaitWorkPersistence(operation: Operation) { + operation.await() +} + +/** Invokes every mutation and awaits every returned transaction before surfacing any failure. */ +internal suspend fun awaitWorkMutations(mutations: List<() -> Operation>) { + var firstFailure: Throwable? = null + val operations = buildList { + mutations.forEach { mutation -> + try { + add(mutation()) + } catch (failure: Throwable) { + val existing = firstFailure + if (existing == null) firstFailure = failure else existing.addSuppressed(failure) + } + } + } + operations.forEach { operation -> + try { + awaitWorkPersistence(operation) + } catch (failure: Throwable) { + val existing = firstFailure + if (existing == null) firstFailure = failure else existing.addSuppressed(failure) + } + } + firstFailure?.let { throw it } +} + +/** Notification cleanup cannot prevent any WorkManager cancellation from being attempted. */ +internal suspend fun awaitCleanupMutations( + notificationCleanup: () -> Unit, + mutations: List<() -> Operation>, +) = withContext(NonCancellable) { + var firstFailure: Throwable? = try { + notificationCleanup() + null + } catch (failure: Throwable) { + failure + } + try { + awaitWorkMutations(mutations) + } catch (failure: Throwable) { + val existing = firstFailure + if (existing == null) firstFailure = failure else existing.addSuppressed(failure) + } + firstFailure?.let { throw it } +} + +internal object SafetyPauseWorkIdentity { + const val COMMON_TAG = "particeps-safety-pause" + private const val STUDY_TAG_PREFIX = "particeps-safety-pause-study:" + private const val REASON_TAG_PREFIX = "particeps-safety-pause-reason:" + + fun workName(experimentId: String, reason: SafetyPauseReason) = + "particeps-safety-pause-${studyIdentity(experimentId)}-${reason.name}" + + fun tags(experimentId: String, reason: SafetyPauseReason): Set = setOf( + COMMON_TAG, + studyTag(experimentId), + "$REASON_TAG_PREFIX${reason.name}", + ) + + fun activeReason(experimentId: String, activeWorkTags: List>): SafetyPauseReason? { + val decoded = activeWorkTags.map { tags -> + check(COMMON_TAG in tags) { "Active safety-pause work is missing its common tag" } + val studyTags = tags.filter { it.startsWith(STUDY_TAG_PREFIX) } + val reasonTags = tags.filter { it.startsWith(REASON_TAG_PREFIX) } + check(studyTags.size == 1 && reasonTags.size == 1) { + "Active safety-pause work has malformed identity tags" + } + val reasonName = reasonTags.single().removePrefix(REASON_TAG_PREFIX) + val reason = SafetyPauseReason.entries.singleOrNull { it.name == reasonName } + ?: error("Active safety-pause work has an unknown reason") + studyTags.single() to reason + } + val reasons = decoded + .filter { (tag, _) -> tag == studyTag(experimentId) } + .mapTo(mutableSetOf()) { (_, reason) -> reason } + check(reasons.size <= 1) { "Multiple active safety-pause reasons exist for one study" } + return reasons.singleOrNull() + } + + private fun studyTag(experimentId: String) = "$STUDY_TAG_PREFIX${studyIdentity(experimentId)}" + + private fun studyIdentity(experimentId: String): String = MessageDigest + .getInstance("SHA-256") + .digest(experimentId.toByteArray(Charsets.UTF_8)) + .joinToString("") { byte -> "%02x".format(byte) } +} + internal object InterventionWorkIdentity { fun deliveryTag(experimentId: String) = "particeps-intervention-delivery-$experimentId" fun expiryTag(experimentId: String) = "particeps-intervention-expiry-$experimentId" @@ -395,17 +623,10 @@ class InterventionWorker( val manager = applicationContext.getSystemService(NotificationManager::class.java) var finalized = false try { - manager.createNotificationChannel( - NotificationChannel( - CHANNEL_ID, - applicationContext.getString(R.string.intervention_channel), - NotificationManager.IMPORTANCE_DEFAULT, - ), - ) manager.notify( occurrenceId, 0, - android.app.Notification.Builder(applicationContext, CHANNEL_ID) + android.app.Notification.Builder(applicationContext, ParticepsNotificationChannels.INTERVENTIONS) .setSmallIcon(android.R.drawable.ic_dialog_info) .setContentTitle(dispatch.action.notificationTitle) .setContentText(dispatch.action.notificationMessage) @@ -440,7 +661,6 @@ class InterventionWorker( companion object { const val KEY_OCCURRENCE_ID = "occurrence_id" const val ACTION_OPEN_OCCURRENCE = "cool.jacoblin.particeps.OPEN_OCCURRENCE" - private const val CHANNEL_ID = "research-interventions-v1" } } diff --git a/app/src/main/kotlin/cool/jacoblin/particeps/platform/AtomicSafetyPauseStore.kt b/app/src/main/kotlin/cool/jacoblin/particeps/platform/AtomicSafetyPauseStore.kt new file mode 100644 index 0000000..6727cbd --- /dev/null +++ b/app/src/main/kotlin/cool/jacoblin/particeps/platform/AtomicSafetyPauseStore.kt @@ -0,0 +1,62 @@ +package cool.jacoblin.particeps.platform + +import android.content.Context +import cool.jacoblin.particeps.core.application.SafetyPauseStore +import cool.jacoblin.particeps.core.model.SafetyPauseReason +import cool.jacoblin.particeps.core.storage.AcknowledgedAtomicFile +import cool.jacoblin.particeps.core.storage.AcknowledgedFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** Acknowledged, app-private persistence for one typed reason without study or participant identity. */ +class AtomicSafetyPauseStore internal constructor( + private val file: AcknowledgedFile, +) : SafetyPauseStore { + constructor(context: Context) : this( + AcknowledgedAtomicFile(context.noBackupFilesDir.resolve(FILE_NAME)), + ) + + private val mutex = Mutex() + + override suspend fun pendingReason(): SafetyPauseReason? = mutex.withLock { + withContext(Dispatchers.IO) { + if (!file.exists()) return@withContext null + SafetyPauseMarkerCodec.decode(file.readFully()) + } + } + + override suspend fun markPending(reason: SafetyPauseReason) = mutex.withLock { + withContext(Dispatchers.IO) { + file.write(SafetyPauseMarkerCodec.encode(reason)) + } + } + + override suspend fun clear() = mutex.withLock { + withContext(Dispatchers.IO) { file.delete() } + } + + private companion object { + const val FILE_NAME = "safety-pause.marker" + } +} + +/** Strict codec: an unknown reason or any extra/truncated bytes keeps recovery fail-closed. */ +internal object SafetyPauseMarkerCodec { + private const val MAGIC = "PARTICEPS_SAFETY_PAUSE_V1" + private const val MAXIMUM_MARKER_BYTES = 96 + + fun encode(reason: SafetyPauseReason): ByteArray = + "$MAGIC\n${reason.name}\n".toByteArray(Charsets.US_ASCII) + + fun decode(bytes: ByteArray): SafetyPauseReason { + check(bytes.size in 1..MAXIMUM_MARKER_BYTES) { "Safety-pause marker has an invalid size" } + val fields = bytes.toString(Charsets.US_ASCII).split('\n') + check(fields.size == 3 && fields[0] == MAGIC && fields[2].isEmpty()) { + "Safety-pause marker is corrupt" + } + return SafetyPauseReason.entries.singleOrNull { it.name == fields[1] } + ?: error("Safety-pause marker has an unknown reason") + } +} diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index aa5bc36..f2a480e 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -5,7 +5,14 @@ --> 研究活動 + 排程的研究活動與問卷 每日研究提醒 + 每日安靜提醒研究目前正在收集或已暫停 + 研究資料收集中 + 參與者同意的研究正在收集資料時顯示 + 研究資料收集中 + 正在確認是否應繼續收集 + 正在確認研究狀態 仍在收集中。開啟 App 可以暫停、匯出或提前結束。 自 %1$s 暫停中。在你重新開始之前不會收集任何資料。 目前暫停中。在你重新開始之前不會收集任何資料。 @@ -54,6 +61,9 @@ 刪除本機資料 確認 取消 + 允許 + 開啟 Android 設定 + 選擇鍵盤 語言 詳細資訊 @@ -152,6 +162,7 @@ Wi-Fi 或行動網路 精確位置 + Android 定位服務 背景位置 通知 使用情況存取權 @@ -161,6 +172,51 @@ 陀螺儀 環境光感測器 接近感測器 + 使用這項設定 + • %1$s + 研究狀態與持續收集通知;設定排程活動時也用於活動通知 + 手動操作步驟 + 選用資料來源需要設定 + 研究會繼續執行。恢復下列設定後,只有使用該設定的選用資料來源會繼續收集。 + %1$d. %2$s + 請先完成「%1$s」。 + 這支手機沒有提供此感測器,無法透過設定啟用,也不會使用替代資料。 + Android 回報這支手機無法滿足本研究要求的位置設定。開始前請聯絡研究團隊。 + Particeps 無法確認這支手機是否能滿足研究設定的位置請求。開始前請聯絡研究團隊。 + 這支手機沒有提供所需的 Android 設定畫面,請先聯絡研究團隊,不要開始研究。 + + 多次拒絕後,Android 不會再顯示權限對話框。請從 Particeps 的應用程式資訊恢復精確位置。 + 在「應用程式資訊」中開啟「權限」,再選擇「位置」。 + 選擇「僅在使用應用程式時允許」,並開啟「使用精確位置」。 + 返回 Particeps;App 會自動檢查實際的 Android 設定。 + + Android 必須能滿足本研究設定的精確位置請求,包括準確度與更新設定。 + 開啟「使用位置」,以及本研究所需的位置準確度服務。 + 返回 Particeps;App 會自動檢查研究設定的完整位置請求。 + + Android 要求另外設定才能持續收集位置。Particeps 關閉或未使用時,這個研究仍可能在背景收集位置。 + 在「應用程式資訊」中開啟「權限」,再選擇「位置」。 + 選擇「%1$s」。 + 返回 Particeps;App 會自動檢查實際的 Android 設定。 + + 如果權限對話框已無法授權,或 Particeps 必要的通知類別被關閉,就必須從 Android 設定恢復。 + 開啟「允許通知」。 + 保持「研究資料收集中」與「每日研究提醒」開啟;若本研究有排程活動,也請保持「研究活動」開啟。 + 返回 Particeps;App 會自動檢查權限與必要通知類別。 + + Android 將 App 與網路使用情況視為特殊存取權,因此無法透過一般權限對話框啟用。 + 在「使用情況存取權」畫面找到並開啟 Particeps。 + 開啟「允許使用情況存取權」。 + 返回 Particeps;App 會自動檢查實際的 Android 設定。 + + 研究鍵盤是 Android 輸入法,必須先在系統設定中啟用,之後才能選用。 + 開啟「管理螢幕小鍵盤」。 + 啟用「研究鍵盤」,並確認 Android 顯示的警告。 + 啟用後返回 Particeps。 + + 啟用研究鍵盤後,請在 Android 鍵盤選擇器中將它設為目前鍵盤。 + 在系統鍵盤選擇器中選擇「研究鍵盤」。 + 返回 Particeps;App 會自動檢查目前選用的鍵盤。 %1$s 筆 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 312242f..edef455 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,8 +1,9 @@ Particeps Research activities + Scheduled study activities and surveys Daily study reminder + A quiet daily reminder that the study is collecting or paused + Active research collection + Shown while a consented research study is collecting data + Research collection active + Checking whether collection should continue + Restoring research status Still collecting. Open the app to pause, export, or finish early. Paused since %1$s. Nothing is being collected until you resume. @@ -75,6 +83,9 @@ Delete local data Confirm Cancel + Allow + Open Android settings + Choose keyboard Language @@ -187,6 +198,7 @@ Precise location + Android location services Background location Notifications Usage access @@ -196,6 +208,51 @@ Gyroscope Ambient-light sensor Proximity sensor + Used by + • %1$s + Study status and ongoing collection; scheduled activities when configured + Manual steps + Optional sources need setup + The study keeps running. Restore any item below to resume only the optional sources that use it. + %1$d. %2$s + Complete %1$s first. + This phone does not provide this sensor. There is no setting or substitute that can enable it. + Android reports that this phone cannot satisfy the location settings required by this study. Contact the research team before starting. + Particeps could not verify whether this phone can satisfy the study’s configured location request. Contact the research team before starting. + This phone does not provide the required Android settings screen. Contact the research team before starting. + + Android no longer offers the permission dialog after repeated denial. Restore precise location from Particeps App info. + In App info, open Permissions, then Location. + Choose Allow only while using the app and turn on Use precise location. + Return to Particeps; it checks the real Android setting automatically. + + Android must be able to satisfy this study’s exact location request, including its configured accuracy and update settings. + Turn on Use location and the location-accuracy services required by this study. + Return to Particeps; it checks the exact configured location request automatically. + + For continuous location collection, Android requires a separate setting. This study may collect location in the background while Particeps is closed or not in use. + In App info, open Permissions, then Location. + Choose “%1$s”. + Return to Particeps; it checks the real Android setting automatically. + + Android settings are required when the permission dialog can no longer grant access or a required Particeps notification channel is off. + Turn on Allow notifications. + Keep Active research collection and Daily study reminder enabled. If this study has scheduled activities, keep Research activities enabled too. + Return to Particeps; it checks the permission and required channels automatically. + + Android treats app and network usage as special access, so a normal permission dialog cannot enable it. + Find and open Particeps on the Usage access screen. + Turn on Permit usage access. + Return to Particeps; it checks the real Android setting automatically. + + The research keyboard is an Android input method and must be enabled in system settings before it can be selected. + Open Manage on-screen keyboards. + Enable Research keyboard and confirm Android’s warning. + Return to Particeps after the keyboard is enabled. + + After enabling the research keyboard, choose it as the current keyboard in Android’s picker. + Choose Research keyboard in the system keyboard picker. + Return to Particeps; it checks the selected keyboard automatically. diff --git a/app/src/test/kotlin/cool/jacoblin/particeps/ExperimentDeadlineWorkerPolicyTest.kt b/app/src/test/kotlin/cool/jacoblin/particeps/ExperimentDeadlineWorkerPolicyTest.kt new file mode 100644 index 0000000..738a6a8 --- /dev/null +++ b/app/src/test/kotlin/cool/jacoblin/particeps/ExperimentDeadlineWorkerPolicyTest.kt @@ -0,0 +1,38 @@ +package cool.jacoblin.particeps + +import cool.jacoblin.particeps.core.application.DurationCompletionResult +import cool.jacoblin.particeps.core.runtime.CommandResult +import org.junit.Assert.assertEquals +import org.junit.Test + +class ExperimentDeadlineWorkerPolicyTest { + @Test + fun earlyDeadlineRetriesInsteadOfCompletingOrFailing() { + assertEquals( + DeadlineWorkDisposition.RETRY, + DurationCompletionResult.NotDue(remainingMillis = 1) + .deadlineWorkDisposition(), + ) + } + + @Test + fun completedAndInactiveDeadlinesFinishIdempotently() { + assertEquals( + DeadlineWorkDisposition.SUCCESS, + DurationCompletionResult.Completed.deadlineWorkDisposition(), + ) + assertEquals( + DeadlineWorkDisposition.SUCCESS, + DurationCompletionResult.Inactive.deadlineWorkDisposition(), + ) + } + + @Test + fun failedDeadlineCompletionDoesNotEnterAnUnboundedRetryLoop() { + assertEquals( + DeadlineWorkDisposition.FAILURE, + DurationCompletionResult.Failed(CommandResult.Failed("STORAGE_WRITE_FAILED")) + .deadlineWorkDisposition(), + ) + } +} diff --git a/app/src/test/kotlin/cool/jacoblin/particeps/SafetyPauseWorkerPolicyTest.kt b/app/src/test/kotlin/cool/jacoblin/particeps/SafetyPauseWorkerPolicyTest.kt new file mode 100644 index 0000000..7a679fe --- /dev/null +++ b/app/src/test/kotlin/cool/jacoblin/particeps/SafetyPauseWorkerPolicyTest.kt @@ -0,0 +1,111 @@ +package cool.jacoblin.particeps + +import cool.jacoblin.particeps.core.application.SafetyPauseStatus +import cool.jacoblin.particeps.core.application.StudySessionSnapshot +import cool.jacoblin.particeps.core.definition.AppLifecycleConfiguration +import cool.jacoblin.particeps.core.definition.ExportConfiguration +import cool.jacoblin.particeps.core.definition.SignerIdentity +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.model.SafetyPauseReason +import java.time.Instant +import org.junit.Assert.assertEquals +import org.junit.Test + +class SafetyPauseWorkerPolicyTest { + @Test + fun invalidIdentityRetriesInsteadOfDiscardingFailClosedEvidence() { + val snapshot = StudySessionSnapshot(initialized = true) + + assertEquals( + SafetyPauseWorkerDecision.Retry, + safetyPauseWorkerDecision(null, SafetyPauseReason.REQUIRED_ACCESS_MISSING.name, snapshot), + ) + assertEquals( + SafetyPauseWorkerDecision.Retry, + safetyPauseWorkerDecision("study", "UNKNOWN", snapshot), + ) + } + + @Test + fun blockedRecoveryAndTypedMarkerRetainWorkWithoutConfiguration() { + val recoveryFailure = StudySessionSnapshot( + initialized = true, + recoveryBlocked = true, + incidentCode = "STUDY_IMPORT_FAILED", + ) + val pendingMarker = StudySessionSnapshot( + initialized = true, + safetyPauseStatus = SafetyPauseStatus.Pending( + SafetyPauseReason.COLLECTION_HOST_FAILURE, + ), + ) + + assertEquals( + SafetyPauseWorkerDecision.Retry, + safetyPauseWorkerDecision( + "study", + SafetyPauseReason.COLLECTION_HOST_FAILURE.name, + recoveryFailure, + ), + ) + assertEquals( + SafetyPauseWorkerDecision.Retry, + safetyPauseWorkerDecision( + "study", + SafetyPauseReason.COLLECTION_HOST_FAILURE.name, + pendingMarker, + ), + ) + } + + @Test + fun absentOrDifferentStudyCompletesButMatchingStudyAttemptsRetry() { + val reason = SafetyPauseReason.REQUIRED_ACCESS_MISSING + assertEquals( + SafetyPauseWorkerDecision.Complete, + safetyPauseWorkerDecision("study", reason.name, StudySessionSnapshot(initialized = true)), + ) + + val active = StudySessionSnapshot( + initialized = true, + configuration = configuration("study"), + ) + assertEquals( + SafetyPauseWorkerDecision.Complete, + safetyPauseWorkerDecision("other", reason.name, active), + ) + assertEquals( + SafetyPauseWorkerDecision.Attempt("study", reason), + safetyPauseWorkerDecision("study", reason.name, active), + ) + } + + private fun configuration(experimentId: String): StudyConfiguration = StudyConfiguration( + schemaVersion = StudyConfiguration.CURRENT_SCHEMA_VERSION, + experimentId = experimentId, + configurationId = "configuration", + issuedAt = Instant.parse("2026-01-01T00:00:00Z"), + expiresAt = Instant.parse("2030-01-01T00:00:00Z"), + platform = StudyConfiguration.ANDROID_PLATFORM, + minimumClientVersion = 1, + title = "Study", + researcherName = "Researcher", + researcherContact = "researcher@example.invalid", + purpose = "Exercise the safety-pause worker policy.", + durationHours = 1, + consentDocumentVersion = "v1", + consentSummary = "Test consent.", + assignedParticipantId = null, + collectors = listOf(AppLifecycleConfiguration(required = true)), + surveys = emptyList(), + interventions = emptyList(), + maximumLocalBytes = 16_777_216, + signer = SignerIdentity("test-signer", RAW_PUBLIC_KEY), + export = ExportConfiguration("export-key", RAW_PUBLIC_KEY), + upload = null, + ) + + private companion object { + const val RAW_PUBLIC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } +} diff --git a/app/src/test/kotlin/cool/jacoblin/particeps/platform/AtomicSafetyPauseStoreTest.kt b/app/src/test/kotlin/cool/jacoblin/particeps/platform/AtomicSafetyPauseStoreTest.kt new file mode 100644 index 0000000..7d9b172 --- /dev/null +++ b/app/src/test/kotlin/cool/jacoblin/particeps/platform/AtomicSafetyPauseStoreTest.kt @@ -0,0 +1,77 @@ +package cool.jacoblin.particeps.platform + +import cool.jacoblin.particeps.core.model.SafetyPauseReason +import cool.jacoblin.particeps.core.storage.AcknowledgedFile +import cool.jacoblin.particeps.core.storage.IncompleteAtomicWrite +import java.io.File +import java.io.IOException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class AtomicSafetyPauseStoreTest { + @Test + fun acknowledgedWriteFailurePropagatesToTheTypedWorkerFallbackCaller() { + val file = FakeAcknowledgedFile().apply { + writeFailure = IOException("injected marker durability failure") + } + val store = AtomicSafetyPauseStore(file) + + assertThrows(IOException::class.java) { + runBlocking { store.markPending(SafetyPauseReason.STORAGE_FAILURE) } + } + + assertEquals(null, file.bytes) + } + + @Test + fun acknowledgedDeleteFailureCannotClearTheMarker() { + val file = FakeAcknowledgedFile().apply { + bytes = SafetyPauseMarkerCodec.encode(SafetyPauseReason.STORAGE_FAILURE) + deleteFailure = IOException("injected marker delete failure") + } + val store = AtomicSafetyPauseStore(file) + + assertThrows(IOException::class.java) { runBlocking { store.clear() } } + + assertEquals(SafetyPauseReason.STORAGE_FAILURE, runBlocking { store.pendingReason() }) + } + + @Test + fun incompleteAtomicMarkerBlocksRecoveryInsteadOfAppearingAbsent() { + val file = FakeAcknowledgedFile().apply { + incompleteWrite = true + } + val reopenedStore = AtomicSafetyPauseStore(file) + + assertThrows(IncompleteAtomicWrite::class.java) { + runBlocking { reopenedStore.pendingReason() } + } + } + + private class FakeAcknowledgedFile : AcknowledgedFile { + override val baseFile = File("unused-safety-marker") + var bytes: ByteArray? = null + var writeFailure: IOException? = null + var deleteFailure: IOException? = null + var incompleteWrite = false + + override fun exists(): Boolean = bytes != null || incompleteWrite + + override fun readFully(): ByteArray { + if (incompleteWrite) throw IncompleteAtomicWrite(baseFile) + return requireNotNull(bytes).copyOf() + } + + override fun write(bytes: ByteArray) { + writeFailure?.let { throw it } + this.bytes = bytes.copyOf() + } + + override fun delete() { + deleteFailure?.let { throw it } + bytes = null + } + } +} diff --git a/app/src/test/kotlin/cool/jacoblin/particeps/platform/CollectionWorkPlanTest.kt b/app/src/test/kotlin/cool/jacoblin/particeps/platform/CollectionWorkPlanTest.kt new file mode 100644 index 0000000..49242a1 --- /dev/null +++ b/app/src/test/kotlin/cool/jacoblin/particeps/platform/CollectionWorkPlanTest.kt @@ -0,0 +1,156 @@ +package cool.jacoblin.particeps.platform + +import androidx.work.ExistingWorkPolicy +import cool.jacoblin.particeps.core.definition.AppLifecycleConfiguration +import cool.jacoblin.particeps.core.definition.ExportConfiguration +import cool.jacoblin.particeps.core.definition.SignerIdentity +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.definition.UploadConfiguration +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.ExperimentTransition +import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.TransitionReason +import java.time.Instant +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class CollectionWorkPlanTest { + @Test + fun activeRc5DeadlineIsReplacedFromTheImmutableStartInsteadOfKeptOrReset() { + val configuration = configuration() + val start = ResearchTime(1_000, 1_000_000_000, "boot-one") + + val plan = collectionWorkPlan( + configuration, + startedMetadata(start, ExperimentState.RUNNING), + ResearchTime(101_000, 101_000_000_000, "boot-one"), + ) + + assertEquals(ExistingWorkPolicy.REPLACE, plan.deadlinePolicy) + assertEquals(3_500_000L, plan.deadlineDelayMillis) + assertTrue(plan.scheduleDailyStatus) + assertTrue(plan.scheduleUpload) + } + + @Test + fun resumeAndTimeChangeRepairUseTheOriginalElapsedTime() { + val configuration = configuration() + val start = ResearchTime(1_000, 1_000_000_000, "boot-one") + val paused = startedMetadata(start, ExperimentState.PAUSED) + + val afterResume = collectionWorkPlan( + configuration, + paused, + ResearchTime(10_001_000, 601_000_000_000, "boot-one"), + ) + + assertEquals(3_000_000L, afterResume.deadlineDelayMillis) + assertEquals(ExistingWorkPolicy.REPLACE, afterResume.deadlinePolicy) + } + + @Test + fun rebootRepairFailsClosedEvenWhenWallClockHasNotCrossedTheStart() { + val configuration = configuration() + val start = ResearchTime(1_000_000, 900_000_000_000, "boot-one") + val metadata = startedMetadata(start, ExperimentState.RUNNING) + + assertThrows(IllegalArgumentException::class.java) { + collectionWorkPlan( + configuration, + metadata, + ResearchTime(1_600_000, 50, "boot-two"), + ) + } + } + + @Test + fun terminalRepairKeepsOnlyTheUploadTail() { + val configuration = configuration() + + val plan = collectionWorkPlan( + configuration, + startedMetadata( + ResearchTime(1_000, 1_000_000, "boot-one"), + ExperimentState.COMPLETED, + ), + ResearchTime(3_601_000, 1, "boot-two"), + ) + + assertNull(plan.deadlineDelayMillis) + assertFalse(plan.scheduleDailyStatus) + assertTrue(plan.scheduleUpload) + } + + private fun startedMetadata(start: ResearchTime, state: ExperimentState): StudyMetadata { + val transitions = mutableListOf( + ExperimentTransition( + from = ExperimentState.READY, + to = ExperimentState.RUNNING, + reason = TransitionReason.PARTICIPANT_STARTED, + time = start, + ), + ) + when (state) { + ExperimentState.RUNNING -> Unit + ExperimentState.PAUSED -> transitions += ExperimentTransition( + from = ExperimentState.RUNNING, + to = ExperimentState.PAUSED, + reason = TransitionReason.PARTICIPANT_PAUSED, + time = start.copy( + wallTimeUtcMillis = start.wallTimeUtcMillis + 1, + elapsedRealtimeNanos = start.elapsedRealtimeNanos + 1, + ), + ) + ExperimentState.COMPLETED -> transitions += ExperimentTransition( + from = ExperimentState.RUNNING, + to = ExperimentState.COMPLETED, + reason = TransitionReason.PARTICIPANT_FINISHED_EARLY, + time = start.copy( + wallTimeUtcMillis = start.wallTimeUtcMillis + 1, + elapsedRealtimeNanos = start.elapsedRealtimeNanos + 1, + ), + ) + else -> error("Unsupported fixture state: $state") + } + return StudyMetadata.initial(EXPERIMENT_ID, CONFIGURATION_ID).copy( + state = state, + transitions = transitions, + ) + } + + private fun configuration() = StudyConfiguration( + schemaVersion = StudyConfiguration.CURRENT_SCHEMA_VERSION, + experimentId = EXPERIMENT_ID, + configurationId = CONFIGURATION_ID, + issuedAt = Instant.parse("2026-01-01T00:00:00Z"), + expiresAt = Instant.parse("2030-01-01T00:00:00Z"), + platform = StudyConfiguration.ANDROID_PLATFORM, + minimumClientVersion = 1, + title = "Work plan test", + researcherName = "Researcher", + researcherContact = "researcher@example.invalid", + purpose = "Test durable WorkManager planning.", + durationHours = 1, + consentDocumentVersion = "v1", + consentSummary = "Test consent.", + assignedParticipantId = null, + collectors = listOf(AppLifecycleConfiguration(required = true)), + surveys = emptyList(), + interventions = emptyList(), + maximumLocalBytes = 16_777_216, + signer = SignerIdentity("test-signer", RAW_PUBLIC_KEY), + export = ExportConfiguration("export-key", RAW_PUBLIC_KEY), + upload = UploadConfiguration("https://intake.example.invalid/v1", 60, false), + ) + + private companion object { + const val EXPERIMENT_ID = "work-plan-study" + const val CONFIGURATION_ID = "work-plan-config" + const val RAW_PUBLIC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } +} diff --git a/app/src/test/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploaderTest.kt b/app/src/test/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploaderTest.kt index 9dc9b5c..5da4d9f 100644 --- a/app/src/test/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploaderTest.kt +++ b/app/src/test/kotlin/cool/jacoblin/particeps/platform/OkHttpStudyUploaderTest.kt @@ -321,7 +321,14 @@ class OkHttpStudyUploaderTest { override suspend fun initialize(metadata: StudyMetadata) = error("unused") override suspend fun saveMetadata(metadata: StudyMetadata) = error("unused") override suspend fun appendEvent(event: RecordedEvent) = error("unused") - override suspend fun appendEventAtomically(event: RecordedEvent, metadata: StudyMetadata) = error("unused") + override suspend fun appendEventAtomically( + event: RecordedEvent, + metadata: StudyMetadata, + failureTime: ResearchTime, + ) = error("unused") + override suspend fun resolvePendingAppendFailure( + reason: cool.jacoblin.particeps.core.model.TransitionReason, + ): StudyMetadata? = error("unused") override suspend fun readEvents( fromSequenceInclusive: Long, upToSequenceInclusive: Long, @@ -385,7 +392,14 @@ class OkHttpStudyUploaderTest { override suspend fun initialize(metadata: StudyMetadata) = error("unused") override suspend fun saveMetadata(metadata: StudyMetadata) = error("unused") override suspend fun appendEvent(event: RecordedEvent) = error("unused") - override suspend fun appendEventAtomically(event: RecordedEvent, metadata: StudyMetadata) = error("unused") + override suspend fun appendEventAtomically( + event: RecordedEvent, + metadata: StudyMetadata, + failureTime: ResearchTime, + ) = error("unused") + override suspend fun resolvePendingAppendFailure( + reason: cool.jacoblin.particeps.core.model.TransitionReason, + ): StudyMetadata? = error("unused") override suspend fun readEvents( fromSequenceInclusive: Long, upToSequenceInclusive: Long, diff --git a/app/src/test/kotlin/cool/jacoblin/particeps/platform/SafetyPauseMarkerCodecTest.kt b/app/src/test/kotlin/cool/jacoblin/particeps/platform/SafetyPauseMarkerCodecTest.kt new file mode 100644 index 0000000..4b1677a --- /dev/null +++ b/app/src/test/kotlin/cool/jacoblin/particeps/platform/SafetyPauseMarkerCodecTest.kt @@ -0,0 +1,90 @@ +package cool.jacoblin.particeps.platform + +import cool.jacoblin.particeps.core.model.SafetyPauseReason +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class SafetyPauseMarkerCodecTest { + @Test + fun everyClosedReasonRoundTripsWithoutIdentityData() { + SafetyPauseReason.entries.forEach { reason -> + val encoded = SafetyPauseMarkerCodec.encode(reason) + + assertEquals(reason, SafetyPauseMarkerCodec.decode(encoded)) + assertEquals( + "PARTICEPS_SAFETY_PAUSE_V1\n${reason.name}\n", + encoded.toString(Charsets.US_ASCII), + ) + } + } + + @Test + fun unknownTruncatedAndExtendedMarkersAreRejected() { + listOf( + "PARTICEPS_SAFETY_PAUSE_V1\nUNKNOWN_REASON\n", + "PARTICEPS_SAFETY_PAUSE_V1\nCOLLECTION_HOST_FAILURE", + "PARTICEPS_SAFETY_PAUSE_V1\nCOLLECTION_HOST_FAILURE\nextra\n", + ).forEach { marker -> + val failure = runCatching { + SafetyPauseMarkerCodec.decode(marker.toByteArray(Charsets.US_ASCII)) + }.exceptionOrNull() + + check(failure is IllegalStateException) + } + } +} + +class SafetyPauseWorkIdentityTest { + @Test + fun workIdentityIsScopedByStudyAndClosedReason() { + val hostFailureTags = SafetyPauseWorkIdentity.tags( + "study-a", + SafetyPauseReason.COLLECTION_HOST_FAILURE, + ) + + assertEquals( + SafetyPauseReason.COLLECTION_HOST_FAILURE, + SafetyPauseWorkIdentity.activeReason("study-a", listOf(hostFailureTags)), + ) + assertNull(SafetyPauseWorkIdentity.activeReason("study-b", listOf(hostFailureTags))) + assertNotEquals( + SafetyPauseWorkIdentity.workName("study-a", SafetyPauseReason.COLLECTION_HOST_FAILURE), + SafetyPauseWorkIdentity.workName("study-a", SafetyPauseReason.REQUIRED_ACCESS_MISSING), + ) + assertNotEquals( + SafetyPauseWorkIdentity.workName("study-a", SafetyPauseReason.COLLECTION_HOST_FAILURE), + SafetyPauseWorkIdentity.workName("study-b", SafetyPauseReason.COLLECTION_HOST_FAILURE), + ) + } + + @Test + fun malformedUnknownAndMultipleActiveReasonsAreRejected() { + val accessTags = SafetyPauseWorkIdentity.tags( + "study-a", + SafetyPauseReason.REQUIRED_ACCESS_MISSING, + ) + val hostTags = SafetyPauseWorkIdentity.tags( + "study-a", + SafetyPauseReason.COLLECTION_HOST_FAILURE, + ) + val unknownTags = accessTags + .filterNot { it.startsWith("particeps-safety-pause-reason:") } + .toSet() + "particeps-safety-pause-reason:UNKNOWN" + + assertThrows(IllegalStateException::class.java) { + SafetyPauseWorkIdentity.activeReason("study-a", listOf(unknownTags)) + } + assertThrows(IllegalStateException::class.java) { + SafetyPauseWorkIdentity.activeReason("study-a", listOf(accessTags, hostTags)) + } + assertThrows(IllegalStateException::class.java) { + SafetyPauseWorkIdentity.activeReason( + "study-a", + listOf(accessTags - SafetyPauseWorkIdentity.COMMON_TAG), + ) + } + } +} diff --git a/app/src/test/kotlin/cool/jacoblin/particeps/platform/WorkOperationAwaitTest.kt b/app/src/test/kotlin/cool/jacoblin/particeps/platform/WorkOperationAwaitTest.kt new file mode 100644 index 0000000..f0172fc --- /dev/null +++ b/app/src/test/kotlin/cool/jacoblin/particeps/platform/WorkOperationAwaitTest.kt @@ -0,0 +1,114 @@ +package cool.jacoblin.particeps.platform + +import androidx.concurrent.futures.CallbackToFutureAdapter +import androidx.lifecycle.LiveData +import androidx.work.Operation +import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WorkOperationAwaitTest { + @Test + fun waitsForWorkManagerPersistenceAcknowledgement() = runBlocking { + val operation = ControllableOperation() + val awaiting = async { awaitWorkPersistence(operation) } + yield() + + assertFalse(awaiting.isCompleted) + operation.succeed() + + awaiting.await() + assertTrue(awaiting.isCompleted) + } + + @Test + fun propagatesAsynchronousPersistenceFailure() = runBlocking { + supervisorScope { + val operation = ControllableOperation() + val expected = IllegalStateException("WorkManager transaction failed") + val awaiting = async { awaitWorkPersistence(operation) } + yield() + + operation.fail(expected) + val actual = runCatching { awaiting.await() }.exceptionOrNull() + + assertTrue(actual is IllegalStateException) + assertEquals(expected.message, actual?.message) + } + } + + @Test + fun invokesEveryMutationAndAggregatesSynchronousAndAsynchronousFailures() = runBlocking { + supervisorScope { + val invoked = mutableListOf() + val synchronous = IllegalStateException("mutation rejected") + val firstOperation = ControllableOperation() + val secondOperation = ControllableOperation() + val awaiting = async { + awaitWorkMutations( + listOf( + { + invoked += "sync-failure" + throw synchronous + }, + { + invoked += "first-operation" + firstOperation + }, + { + invoked += "second-operation" + secondOperation + }, + ), + ) + } + yield() + + assertEquals( + listOf("sync-failure", "first-operation", "second-operation"), + invoked, + ) + firstOperation.fail(IllegalStateException("first transaction failed")) + secondOperation.fail(IllegalStateException("second transaction failed")) + + val actual = runCatching { awaiting.await() }.exceptionOrNull() + + assertTrue(actual is IllegalStateException) + assertEquals(synchronous.message, actual?.message) + val suppressedMessages = listOfNotNull(actual, actual?.cause) + .flatMap { it.suppressed.toList() } + .map { it.message } + assertEquals( + listOf("first transaction failed", "second transaction failed"), + suppressedMessages, + ) + } + } + + private class ControllableOperation : Operation { + private lateinit var completer: CallbackToFutureAdapter.Completer + private val resultFuture = CallbackToFutureAdapter.getFuture { supplied -> + completer = supplied + "Controllable WorkManager operation" + } + + fun succeed() { + check(completer.set(Operation.SUCCESS)) + } + + fun fail(failure: Throwable) { + check(completer.setException(failure)) + } + + override fun getState(): LiveData = + error("Operation.await must use the completion future") + + override fun getResult(): ListenableFuture = resultFuture + } +} diff --git a/collector/accelerometer/src/main/kotlin/cool/jacoblin/particeps/collector/accelerometer/AccelerometerCollector.kt b/collector/accelerometer/src/main/kotlin/cool/jacoblin/particeps/collector/accelerometer/AccelerometerCollector.kt index e964dbc..d424338 100644 --- a/collector/accelerometer/src/main/kotlin/cool/jacoblin/particeps/collector/accelerometer/AccelerometerCollector.kt +++ b/collector/accelerometer/src/main/kotlin/cool/jacoblin/particeps/collector/accelerometer/AccelerometerCollector.kt @@ -7,7 +7,6 @@ import cool.jacoblin.particeps.collector.sensorcommon.AndroidSensorCollector import cool.jacoblin.particeps.core.model.EventDraft import cool.jacoblin.particeps.core.definition.AccelerometerConfiguration import cool.jacoblin.particeps.core.collector.AccessKind -import cool.jacoblin.particeps.core.collector.AccessRequirement import cool.jacoblin.particeps.core.definition.CollectorConfiguration import cool.jacoblin.particeps.core.collector.PrivacyClass import cool.jacoblin.particeps.core.collector.ProtocolEventContracts @@ -25,15 +24,10 @@ class AccelerometerCollectorPlugin( id = AccelerometerConfiguration.ID, displayName = "Accelerometer", privacyClass = PrivacyClass.SENSITIVE, + accessKinds = setOf(AccessKind.ACCELEROMETER_HARDWARE), eventContract = requireNotNull(ProtocolEventContracts[AccelerometerConfiguration.ID]), ) - override fun accessRequirements(configuration: CollectorConfiguration): Set { - val typed = configuration as? AccelerometerConfiguration - ?: throw IllegalArgumentException("Invalid accelerometer configuration") - return setOf(AccessRequirement(AccessKind.ACCELEROMETER_HARDWARE, typed.required)) - } - override fun create( configuration: CollectorConfiguration, context: CollectorContext, diff --git a/collector/ambient-light/src/main/kotlin/cool/jacoblin/particeps/collector/ambientlight/AmbientLightCollector.kt b/collector/ambient-light/src/main/kotlin/cool/jacoblin/particeps/collector/ambientlight/AmbientLightCollector.kt index b4d731a..f91caed 100644 --- a/collector/ambient-light/src/main/kotlin/cool/jacoblin/particeps/collector/ambientlight/AmbientLightCollector.kt +++ b/collector/ambient-light/src/main/kotlin/cool/jacoblin/particeps/collector/ambientlight/AmbientLightCollector.kt @@ -6,7 +6,6 @@ import android.hardware.SensorEvent import android.os.SystemClock import cool.jacoblin.particeps.collector.sensorcommon.AndroidSensorCollector import cool.jacoblin.particeps.core.collector.AccessKind -import cool.jacoblin.particeps.core.collector.AccessRequirement import cool.jacoblin.particeps.core.collector.Collector import cool.jacoblin.particeps.core.collector.CollectorContext import cool.jacoblin.particeps.core.collector.CollectorDescriptor @@ -28,15 +27,10 @@ class AmbientLightCollectorPlugin(context: Context) : CollectorPlugin { id = AmbientLightConfiguration.ID, displayName = "Ambient light", privacyClass = PrivacyClass.SENSITIVE, + accessKinds = setOf(AccessKind.AMBIENT_LIGHT_HARDWARE), eventContract = requireNotNull(ProtocolEventContracts[AmbientLightConfiguration.ID]), ) - override fun accessRequirements(configuration: CollectorConfiguration): Set { - val typed = configuration as? AmbientLightConfiguration - ?: throw IllegalArgumentException("Invalid ambient-light configuration") - return setOf(AccessRequirement(AccessKind.AMBIENT_LIGHT_HARDWARE, typed.required)) - } - override fun create(configuration: CollectorConfiguration, context: CollectorContext): Collector { val typed = configuration as? AmbientLightConfiguration ?: throw IllegalArgumentException("Invalid ambient-light configuration") diff --git a/collector/app-lifecycle/src/main/kotlin/cool/jacoblin/particeps/collector/applifecycle/AppLifecycleCollector.kt b/collector/app-lifecycle/src/main/kotlin/cool/jacoblin/particeps/collector/applifecycle/AppLifecycleCollector.kt index af35bd5..fceb618 100644 --- a/collector/app-lifecycle/src/main/kotlin/cool/jacoblin/particeps/collector/applifecycle/AppLifecycleCollector.kt +++ b/collector/app-lifecycle/src/main/kotlin/cool/jacoblin/particeps/collector/applifecycle/AppLifecycleCollector.kt @@ -8,7 +8,6 @@ import cool.jacoblin.particeps.core.collector.Collector import cool.jacoblin.particeps.core.collector.CollectorContext import cool.jacoblin.particeps.core.collector.CollectorDescriptor import cool.jacoblin.particeps.core.collector.CollectorPlugin -import cool.jacoblin.particeps.core.collector.AccessRequirement import cool.jacoblin.particeps.core.collector.SerializedCallbackCollector import cool.jacoblin.particeps.core.collector.SourceRegistrationResult import cool.jacoblin.particeps.core.collector.SourceTeardownResult @@ -26,14 +25,10 @@ class AppLifecycleCollectorPlugin( id = COLLECTOR_ID, displayName = "Own-app lifecycle", privacyClass = PrivacyClass.SENSITIVE, + accessKinds = emptySet(), eventContract = requireNotNull(ProtocolEventContracts[COLLECTOR_ID]), ) - override fun accessRequirements(configuration: CollectorConfiguration): Set { - require(configuration is AppLifecycleConfiguration) { "Invalid app-lifecycle configuration" } - return emptySet() - } - override fun create( configuration: CollectorConfiguration, context: CollectorContext, diff --git a/collector/battery-state/src/main/kotlin/cool/jacoblin/particeps/collector/batterystate/BatteryStateCollector.kt b/collector/battery-state/src/main/kotlin/cool/jacoblin/particeps/collector/batterystate/BatteryStateCollector.kt index 8214934..bec863e 100644 --- a/collector/battery-state/src/main/kotlin/cool/jacoblin/particeps/collector/batterystate/BatteryStateCollector.kt +++ b/collector/battery-state/src/main/kotlin/cool/jacoblin/particeps/collector/batterystate/BatteryStateCollector.kt @@ -9,7 +9,6 @@ import android.os.Handler import android.os.Looper import android.os.PowerManager import android.os.SystemClock -import cool.jacoblin.particeps.core.collector.AccessRequirement import cool.jacoblin.particeps.core.collector.Collector import cool.jacoblin.particeps.core.collector.CollectorContext import cool.jacoblin.particeps.core.collector.CollectorDescriptor @@ -37,14 +36,10 @@ class BatteryStateCollectorPlugin(context: Context) : CollectorPlugin { id = BatteryStateConfiguration.ID, displayName = "Battery state", privacyClass = PrivacyClass.SENSITIVE, + accessKinds = emptySet(), eventContract = requireNotNull(ProtocolEventContracts[BatteryStateConfiguration.ID]), ) - override fun accessRequirements(configuration: CollectorConfiguration): Set { - require(configuration is BatteryStateConfiguration) { "Invalid battery-state configuration" } - return emptySet() - } - override fun create(configuration: CollectorConfiguration, context: CollectorContext): Collector { require(configuration is BatteryStateConfiguration) { "Invalid battery-state configuration" } return BatteryStateCollector(applicationContext, context) diff --git a/collector/gyroscope/src/main/kotlin/cool/jacoblin/particeps/collector/gyroscope/GyroscopeCollector.kt b/collector/gyroscope/src/main/kotlin/cool/jacoblin/particeps/collector/gyroscope/GyroscopeCollector.kt index 85cc65b..84af756 100644 --- a/collector/gyroscope/src/main/kotlin/cool/jacoblin/particeps/collector/gyroscope/GyroscopeCollector.kt +++ b/collector/gyroscope/src/main/kotlin/cool/jacoblin/particeps/collector/gyroscope/GyroscopeCollector.kt @@ -5,7 +5,6 @@ import android.hardware.Sensor import android.hardware.SensorEvent import cool.jacoblin.particeps.collector.sensorcommon.AndroidSensorCollector import cool.jacoblin.particeps.core.collector.AccessKind -import cool.jacoblin.particeps.core.collector.AccessRequirement import cool.jacoblin.particeps.core.collector.Collector import cool.jacoblin.particeps.core.collector.CollectorContext import cool.jacoblin.particeps.core.collector.CollectorDescriptor @@ -24,15 +23,10 @@ class GyroscopeCollectorPlugin(context: Context) : CollectorPlugin { id = GyroscopeConfiguration.ID, displayName = "Gyroscope", privacyClass = PrivacyClass.SENSITIVE, + accessKinds = setOf(AccessKind.GYROSCOPE_HARDWARE), eventContract = requireNotNull(ProtocolEventContracts[GyroscopeConfiguration.ID]), ) - override fun accessRequirements(configuration: CollectorConfiguration): Set { - val typed = configuration as? GyroscopeConfiguration - ?: throw IllegalArgumentException("Invalid gyroscope configuration") - return setOf(AccessRequirement(AccessKind.GYROSCOPE_HARDWARE, typed.required)) - } - override fun create(configuration: CollectorConfiguration, context: CollectorContext): Collector { val typed = configuration as? GyroscopeConfiguration ?: throw IllegalArgumentException("Invalid gyroscope configuration") diff --git a/collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/KeyboardTouchCollector.kt b/collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/KeyboardTouchCollector.kt index 3d5edbc..280fe13 100644 --- a/collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/KeyboardTouchCollector.kt +++ b/collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/KeyboardTouchCollector.kt @@ -2,7 +2,6 @@ package cool.jacoblin.particeps.collector.keyboardime import cool.jacoblin.particeps.core.model.EventDraft import cool.jacoblin.particeps.core.collector.AccessKind -import cool.jacoblin.particeps.core.collector.AccessRequirement import cool.jacoblin.particeps.core.definition.CollectorConfiguration import cool.jacoblin.particeps.core.definition.KeyboardTouchConfiguration import cool.jacoblin.particeps.core.collector.PrivacyClass @@ -20,18 +19,13 @@ class KeyboardTouchCollectorPlugin : CollectorPlugin { id = KeyboardTouchConfiguration.ID, displayName = "Research keyboard touch", privacyClass = PrivacyClass.RESTRICTED, + accessKinds = setOf( + AccessKind.RESEARCH_KEYBOARD_ENABLED, + AccessKind.RESEARCH_KEYBOARD_SELECTED, + ), eventContract = requireNotNull(ProtocolEventContracts[KeyboardTouchConfiguration.ID]), ) - override fun accessRequirements(configuration: CollectorConfiguration): Set { - val typed = configuration as? KeyboardTouchConfiguration - ?: throw IllegalArgumentException("Invalid keyboard-touch configuration") - return setOf( - AccessRequirement(AccessKind.RESEARCH_KEYBOARD_ENABLED, typed.required), - AccessRequirement(AccessKind.RESEARCH_KEYBOARD_SELECTED, typed.required), - ) - } - override fun create( configuration: CollectorConfiguration, context: CollectorContext, diff --git a/collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/ResearchKeyboardView.kt b/collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/ResearchKeyboardView.kt index 10d334d..b7b6701 100644 --- a/collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/ResearchKeyboardView.kt +++ b/collector/keyboard-ime/src/main/kotlin/cool/jacoblin/particeps/collector/keyboardime/ResearchKeyboardView.kt @@ -34,7 +34,7 @@ internal class ResearchKeyboardView( "ZXCVBNM".map { KeyboardKey(it.toString().lowercase(), KeyCategory.LETTER) }, listOf( KeyboardKey("⌫", KeyCategory.BACKSPACE), - KeyboardKey("space", KeyCategory.SPACE), + KeyboardKey(context.getString(R.string.keyboard_key_space), KeyCategory.SPACE), KeyboardKey("↵", KeyCategory.ENTER), ), ).flatten() @@ -103,7 +103,13 @@ internal class ResearchKeyboardView( resources.displayMetrics, ) canvas.drawText( - if (collectionAllowed) "Research touch capture active" else "Touch capture disabled for this field", + context.getString( + if (collectionAllowed) { + R.string.keyboard_capture_active + } else { + R.string.keyboard_capture_disabled + }, + ), width / 2f, STATUS_BASELINE_DP * density, statusPaint, diff --git a/collector/keyboard-ime/src/main/res/values-zh-rTW/strings.xml b/collector/keyboard-ime/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..2c7476e --- /dev/null +++ b/collector/keyboard-ime/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,6 @@ + + + 研究觸控資料收集中 + 此欄位已停用觸控資料收集 + 空白 + diff --git a/collector/keyboard-ime/src/main/res/values/strings.xml b/collector/keyboard-ime/src/main/res/values/strings.xml new file mode 100644 index 0000000..1566326 --- /dev/null +++ b/collector/keyboard-ime/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + Research touch capture active + Touch capture disabled for this field + space + diff --git a/collector/location/src/main/kotlin/cool/jacoblin/particeps/collector/location/LocationCollector.kt b/collector/location/src/main/kotlin/cool/jacoblin/particeps/collector/location/LocationCollector.kt index d42dba0..06a8284 100644 --- a/collector/location/src/main/kotlin/cool/jacoblin/particeps/collector/location/LocationCollector.kt +++ b/collector/location/src/main/kotlin/cool/jacoblin/particeps/collector/location/LocationCollector.kt @@ -7,7 +7,6 @@ import android.location.Location import android.os.HandlerThread import cool.jacoblin.particeps.core.model.EventDraft import cool.jacoblin.particeps.core.collector.AccessKind -import cool.jacoblin.particeps.core.collector.AccessRequirement import cool.jacoblin.particeps.core.definition.CollectorConfiguration import cool.jacoblin.particeps.core.definition.LocationConfiguration import cool.jacoblin.particeps.core.definition.LocationPriority @@ -42,18 +41,14 @@ class LocationCollectorPlugin( id = LocationConfiguration.ID, displayName = "Location", privacyClass = PrivacyClass.SENSITIVE, + accessKinds = setOf( + AccessKind.FINE_LOCATION, + AccessKind.LOCATION_SERVICES, + AccessKind.BACKGROUND_LOCATION, + ), eventContract = requireNotNull(ProtocolEventContracts[LocationConfiguration.ID]), ) - override fun accessRequirements(configuration: CollectorConfiguration): Set { - val typed = configuration as? LocationConfiguration - ?: throw IllegalArgumentException("Invalid location configuration") - return setOf( - AccessRequirement(AccessKind.FINE_LOCATION, typed.required), - AccessRequirement(AccessKind.BACKGROUND_LOCATION, typed.required), - ) - } - override fun create( configuration: CollectorConfiguration, context: CollectorContext, diff --git a/collector/network-state/src/main/kotlin/cool/jacoblin/particeps/collector/networkstate/NetworkStateCollector.kt b/collector/network-state/src/main/kotlin/cool/jacoblin/particeps/collector/networkstate/NetworkStateCollector.kt index 6588202..a6bcc4c 100644 --- a/collector/network-state/src/main/kotlin/cool/jacoblin/particeps/collector/networkstate/NetworkStateCollector.kt +++ b/collector/network-state/src/main/kotlin/cool/jacoblin/particeps/collector/networkstate/NetworkStateCollector.kt @@ -5,7 +5,6 @@ import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import cool.jacoblin.particeps.core.model.EventDraft -import cool.jacoblin.particeps.core.collector.AccessRequirement import cool.jacoblin.particeps.core.definition.CollectorConfiguration import cool.jacoblin.particeps.core.definition.NetworkStateConfiguration import cool.jacoblin.particeps.core.collector.PrivacyClass @@ -30,14 +29,10 @@ class NetworkStateCollectorPlugin( id = NetworkStateConfiguration.ID, displayName = "Network connection state", privacyClass = PrivacyClass.SENSITIVE, + accessKinds = emptySet(), eventContract = requireNotNull(ProtocolEventContracts[NetworkStateConfiguration.ID]), ) - override fun accessRequirements(configuration: CollectorConfiguration): Set { - require(configuration is NetworkStateConfiguration) { "Invalid network-state configuration" } - return emptySet() - } - override fun create( configuration: CollectorConfiguration, context: CollectorContext, @@ -82,7 +77,6 @@ private class NetworkStateCollector( register = { connectivityManager.registerDefaultNetworkCallback(callback) callbackRegistered = true - captureCurrentState() }, rollback = { completeSourceTeardown( @@ -93,6 +87,10 @@ private class NetworkStateCollector( ) } + override suspend fun onSourceAdmitted() { + captureCurrentState() + } + override suspend fun unregisterSource(): SourceTeardownResult { completeSourceTeardown( { connectivityManager.unregisterNetworkCallback(callback) }, diff --git a/collector/network-usage/src/main/kotlin/cool/jacoblin/particeps/collector/networkusage/NetworkUsageCollector.kt b/collector/network-usage/src/main/kotlin/cool/jacoblin/particeps/collector/networkusage/NetworkUsageCollector.kt index bc325d4..6654e0b 100644 --- a/collector/network-usage/src/main/kotlin/cool/jacoblin/particeps/collector/networkusage/NetworkUsageCollector.kt +++ b/collector/network-usage/src/main/kotlin/cool/jacoblin/particeps/collector/networkusage/NetworkUsageCollector.kt @@ -4,7 +4,6 @@ import android.app.usage.NetworkStatsManager import android.content.Context import cool.jacoblin.particeps.core.model.EventDraft import cool.jacoblin.particeps.core.collector.AccessKind -import cool.jacoblin.particeps.core.collector.AccessRequirement import cool.jacoblin.particeps.core.definition.CollectorConfiguration import cool.jacoblin.particeps.core.definition.NetworkTransport import cool.jacoblin.particeps.core.definition.NetworkUsageConfiguration @@ -38,15 +37,10 @@ class NetworkUsageCollectorPlugin( id = NetworkUsageConfiguration.ID, displayName = "Aggregate network usage", privacyClass = PrivacyClass.SENSITIVE, + accessKinds = setOf(AccessKind.USAGE_ACCESS), eventContract = requireNotNull(ProtocolEventContracts[NetworkUsageConfiguration.ID]), ) - override fun accessRequirements(configuration: CollectorConfiguration): Set { - val typed = configuration as? NetworkUsageConfiguration - ?: throw IllegalArgumentException("Invalid network-usage configuration") - return setOf(AccessRequirement(AccessKind.USAGE_ACCESS, typed.required)) - } - override fun create( configuration: CollectorConfiguration, context: CollectorContext, diff --git a/collector/proximity/src/main/kotlin/cool/jacoblin/particeps/collector/proximity/ProximityCollector.kt b/collector/proximity/src/main/kotlin/cool/jacoblin/particeps/collector/proximity/ProximityCollector.kt index fa5bc55..73e2f8e 100644 --- a/collector/proximity/src/main/kotlin/cool/jacoblin/particeps/collector/proximity/ProximityCollector.kt +++ b/collector/proximity/src/main/kotlin/cool/jacoblin/particeps/collector/proximity/ProximityCollector.kt @@ -6,7 +6,6 @@ import android.hardware.SensorEvent import android.os.SystemClock import cool.jacoblin.particeps.collector.sensorcommon.AndroidSensorCollector import cool.jacoblin.particeps.core.collector.AccessKind -import cool.jacoblin.particeps.core.collector.AccessRequirement import cool.jacoblin.particeps.core.collector.Collector import cool.jacoblin.particeps.core.collector.CollectorContext import cool.jacoblin.particeps.core.collector.CollectorDescriptor @@ -28,15 +27,10 @@ class ProximityCollectorPlugin(context: Context) : CollectorPlugin { id = ProximityConfiguration.ID, displayName = "Proximity", privacyClass = PrivacyClass.SENSITIVE, + accessKinds = setOf(AccessKind.PROXIMITY_HARDWARE), eventContract = requireNotNull(ProtocolEventContracts[ProximityConfiguration.ID]), ) - override fun accessRequirements(configuration: CollectorConfiguration): Set { - val typed = configuration as? ProximityConfiguration - ?: throw IllegalArgumentException("Invalid proximity configuration") - return setOf(AccessRequirement(AccessKind.PROXIMITY_HARDWARE, typed.required)) - } - override fun create(configuration: CollectorConfiguration, context: CollectorContext): Collector { val typed = configuration as? ProximityConfiguration ?: throw IllegalArgumentException("Invalid proximity configuration") diff --git a/collector/temporal-context/src/main/kotlin/cool/jacoblin/particeps/collector/temporalcontext/TemporalContextCollector.kt b/collector/temporal-context/src/main/kotlin/cool/jacoblin/particeps/collector/temporalcontext/TemporalContextCollector.kt index c8da814..b674e3c 100644 --- a/collector/temporal-context/src/main/kotlin/cool/jacoblin/particeps/collector/temporalcontext/TemporalContextCollector.kt +++ b/collector/temporal-context/src/main/kotlin/cool/jacoblin/particeps/collector/temporalcontext/TemporalContextCollector.kt @@ -7,7 +7,6 @@ import android.content.IntentFilter import android.os.Handler import android.os.Looper import android.os.SystemClock -import cool.jacoblin.particeps.core.collector.AccessRequirement import cool.jacoblin.particeps.core.collector.Collector import cool.jacoblin.particeps.core.collector.CollectorContext import cool.jacoblin.particeps.core.collector.CollectorDescriptor @@ -37,14 +36,10 @@ class TemporalContextCollectorPlugin(context: Context) : CollectorPlugin { id = TemporalContextConfiguration.ID, displayName = "Temporal context", privacyClass = PrivacyClass.SENSITIVE, + accessKinds = emptySet(), eventContract = requireNotNull(ProtocolEventContracts[TemporalContextConfiguration.ID]), ) - override fun accessRequirements(configuration: CollectorConfiguration): Set { - require(configuration is TemporalContextConfiguration) { "Invalid temporal-context configuration" } - return emptySet() - } - override fun create(configuration: CollectorConfiguration, context: CollectorContext): Collector { require(configuration is TemporalContextConfiguration) { "Invalid temporal-context configuration" } return TemporalContextCollector(applicationContext, context) diff --git a/collector/usage-events/src/main/kotlin/cool/jacoblin/particeps/collector/usageevents/UsageEventsCollector.kt b/collector/usage-events/src/main/kotlin/cool/jacoblin/particeps/collector/usageevents/UsageEventsCollector.kt index 99d6c51..84e02c0 100644 --- a/collector/usage-events/src/main/kotlin/cool/jacoblin/particeps/collector/usageevents/UsageEventsCollector.kt +++ b/collector/usage-events/src/main/kotlin/cool/jacoblin/particeps/collector/usageevents/UsageEventsCollector.kt @@ -5,7 +5,6 @@ import android.app.usage.UsageStatsManager import android.content.Context import cool.jacoblin.particeps.core.model.EventDraft import cool.jacoblin.particeps.core.collector.AccessKind -import cool.jacoblin.particeps.core.collector.AccessRequirement import cool.jacoblin.particeps.core.definition.CollectorConfiguration import cool.jacoblin.particeps.core.collector.PrivacyClass import cool.jacoblin.particeps.core.collector.ProtocolEventContracts @@ -38,15 +37,10 @@ class UsageEventsCollectorPlugin( id = UsageEventsConfiguration.ID, displayName = "App and screen usage events", privacyClass = PrivacyClass.SENSITIVE, + accessKinds = setOf(AccessKind.USAGE_ACCESS), eventContract = requireNotNull(ProtocolEventContracts[UsageEventsConfiguration.ID]), ) - override fun accessRequirements(configuration: CollectorConfiguration): Set { - val typed = configuration as? UsageEventsConfiguration - ?: throw IllegalArgumentException("Invalid usage-events configuration") - return setOf(AccessRequirement(AccessKind.USAGE_ACCESS, typed.required)) - } - override fun create( configuration: CollectorConfiguration, context: CollectorContext, diff --git a/core/access/build.gradle.kts b/core/access/build.gradle.kts index cc82964..3db331e 100644 --- a/core/access/build.gradle.kts +++ b/core/access/build.gradle.kts @@ -21,4 +21,8 @@ kotlin { dependencies { api(project(":core:collector-api")) + implementation(libs.coroutines.play.services) + implementation(libs.play.services.location) + testImplementation(libs.coroutines.test) + testImplementation(libs.junit4) } diff --git a/core/access/src/main/kotlin/cool/jacoblin/particeps/core/access/AccessManager.kt b/core/access/src/main/kotlin/cool/jacoblin/particeps/core/access/AccessManager.kt index bd49018..c6893dc 100644 --- a/core/access/src/main/kotlin/cool/jacoblin/particeps/core/access/AccessManager.kt +++ b/core/access/src/main/kotlin/cool/jacoblin/particeps/core/access/AccessManager.kt @@ -2,59 +2,165 @@ package cool.jacoblin.particeps.core.access import android.Manifest import android.app.AppOpsManager +import android.app.NotificationManager import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.hardware.Sensor import android.hardware.SensorManager +import android.location.LocationManager import android.net.Uri import android.os.Build import android.provider.Settings import android.view.inputmethod.InputMethodManager +import cool.jacoblin.particeps.core.collector.AccessInspectionRequest import cool.jacoblin.particeps.core.collector.AccessKind import cool.jacoblin.particeps.core.collector.AccessRequirement -import cool.jacoblin.particeps.core.collector.AccessStatus +import cool.jacoblin.particeps.core.collector.AccessSnapshot +import cool.jacoblin.particeps.core.collector.AccessUnavailableReason +import cool.jacoblin.particeps.core.collector.LocationAccessProfile +import cool.jacoblin.particeps.core.collector.NotificationAccessFeature +import cool.jacoblin.particeps.core.collector.SetupAction +import cool.jacoblin.particeps.core.collector.SetupGuidance import cool.jacoblin.particeps.core.collector.StudyAccessGateway class AccessManager( context: Context, private val keyboardServiceClassName: String, + notificationChannelIdsByFeature: Map, + private val locationSettingsProbe: LocationSettingsProbe = GooglePlayLocationSettingsProbe(context), ) : StudyAccessGateway { private val applicationContext = context.applicationContext + private val permissionState = applicationContext.getSharedPreferences( + PERMISSION_STATE_PREFERENCES, + Context.MODE_PRIVATE, + ) + private val notificationChannelIdsByFeature = validatedNotificationChannelIds(notificationChannelIdsByFeature) - override fun inspect(requirements: Set): List = requirements - .distinctBy { it.kind } - .sortedBy { it.kind.name } - .map { AccessStatus(it, isGranted(it.kind)) } + override suspend fun inspect(request: AccessInspectionRequest): AccessSnapshot { + val requirements = request.requirements + val satisfiedKinds = requirements + .filterTo(mutableSetOf()) { requirement -> + requirement.kind != AccessKind.LOCATION_SERVICES && + isGranted(requirement.kind, request.notificationFeatures) + } + .mapTo(mutableSetOf(), AccessRequirement::kind) + val locationResult = if ( + requirements.any { it.kind == AccessKind.LOCATION_SERVICES } && + AccessRules.byKind.getValue(AccessKind.LOCATION_SERVICES) + .prerequisites.all(satisfiedKinds::contains) + ) { + inspectLocationSettings(requireNotNull(request.locationProfile)) + } else { + null + } + if (locationResult == LocationSettingsProbeResult.READY) { + satisfiedKinds += AccessKind.LOCATION_SERVICES + } + val unavailableSettings = SetupAction.SystemSettings.entries + .filterTo(mutableSetOf()) { action -> settingsIntent(action) == null } + val actionOverrides = mutableMapOf() + val unavailableKinds = mutableMapOf() + when (locationResult) { + LocationSettingsProbeResult.CHANGE_UNAVAILABLE -> unavailableKinds[AccessKind.LOCATION_SERVICES] = + AccessUnavailableReason.LOCATION_SETTINGS_CHANGE_UNAVAILABLE + LocationSettingsProbeResult.CHECK_FAILED -> unavailableKinds[AccessKind.LOCATION_SERVICES] = + AccessUnavailableReason.LOCATION_SETTINGS_CHECK_FAILED + LocationSettingsProbeResult.READY, + LocationSettingsProbeResult.RESOLUTION_REQUIRED, + null -> Unit + } + requirements.forEach { requirement -> + val permission = runtimePermission(requirement.kind) ?: return@forEach + if (requirement.kind in satisfiedKinds) return@forEach + if ( + settingsRequired(requirement.kind) || + requirement.kind == AccessKind.NOTIFICATIONS && permissionGranted(permission) + ) { + actionOverrides[requirement.kind] = when (requirement.kind) { + AccessKind.FINE_LOCATION -> AccessRuleOverride( + SetupAction.SystemSettings.APPLICATION_DETAILS, + SetupGuidance.FOREGROUND_LOCATION_SETTINGS, + ) + AccessKind.NOTIFICATIONS -> AccessRuleOverride( + SetupAction.SystemSettings.APPLICATION_NOTIFICATIONS, + SetupGuidance.NOTIFICATIONS_SETTINGS, + ) + else -> error("Only runtime permissions can have a fixed-denial action") + } + } + } + return AccessRules.resolve( + requirements, + satisfiedKinds, + unavailableSettings, + actionOverrides, + unavailableKinds, + ) + } - override fun grantedKinds(requirements: Set): Set = - inspect(requirements).filter(AccessStatus::granted).mapTo(mutableSetOf()) { it.requirement.kind } + fun recordRuntimePermissionResult( + action: SetupAction.RuntimePermission, + granted: Boolean, + canRequestAgain: Boolean, + ) { + val kind = when (action) { + SetupAction.RuntimePermission.FOREGROUND_LOCATION -> AccessKind.FINE_LOCATION + SetupAction.RuntimePermission.NOTIFICATIONS -> AccessKind.NOTIFICATIONS + } + permissionState.edit() + .putBoolean(settingsRequiredKey(kind), !granted && !canRequestAgain) + .apply() + } - fun settingsIntent(kind: AccessKind): Intent? = when (kind) { - AccessKind.USAGE_ACCESS -> Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS) - AccessKind.RESEARCH_KEYBOARD_ENABLED -> Intent(Settings.ACTION_INPUT_METHOD_SETTINGS) - AccessKind.BACKGROUND_LOCATION -> Intent( - Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.parse("package:${applicationContext.packageName}"), + fun settingsIntent(action: SetupAction.SystemSettings): Intent? { + val unresolved = when (action) { + SetupAction.SystemSettings.APPLICATION_DETAILS -> Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:${applicationContext.packageName}"), + ) + SetupAction.SystemSettings.APPLICATION_NOTIFICATIONS -> Intent( + Settings.ACTION_APP_NOTIFICATION_SETTINGS, + ).putExtra(Settings.EXTRA_APP_PACKAGE, applicationContext.packageName) + SetupAction.SystemSettings.LOCATION_SERVICES -> Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) + SetupAction.SystemSettings.USAGE_ACCESS -> Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS) + SetupAction.SystemSettings.INPUT_METHODS -> Intent(Settings.ACTION_INPUT_METHOD_SETTINGS) + } + val resolved = applicationContext.packageManager.resolveActivity( + unresolved, + PackageManager.ResolveInfoFlags.of( + (PackageManager.MATCH_DEFAULT_ONLY or PackageManager.MATCH_SYSTEM_ONLY).toLong(), + ), + ) ?: return null + return unresolved.setComponent( + ComponentName(resolved.activityInfo.packageName, resolved.activityInfo.name), ) - AccessKind.FINE_LOCATION, - AccessKind.NOTIFICATIONS, - AccessKind.RESEARCH_KEYBOARD_SELECTED, - AccessKind.ACCELEROMETER_HARDWARE, - AccessKind.GYROSCOPE_HARDWARE, - AccessKind.AMBIENT_LIGHT_HARDWARE, - AccessKind.PROXIMITY_HARDWARE -> null } fun showInputMethodPicker() { applicationContext.getSystemService(InputMethodManager::class.java).showInputMethodPicker() } - private fun isGranted(kind: AccessKind): Boolean = when (kind) { + private suspend fun inspectLocationSettings(profile: LocationAccessProfile): LocationSettingsProbeResult { + val globallyEnabled = applicationContext + .getSystemService(LocationManager::class.java) + .isLocationEnabled + return if (globallyEnabled) { + locationSettingsProbe.inspect(profile) + } else { + LocationSettingsProbeResult.RESOLUTION_REQUIRED + } + } + + private fun isGranted( + kind: AccessKind, + notificationFeatures: Set, + ): Boolean = when (kind) { AccessKind.FINE_LOCATION -> permissionGranted(Manifest.permission.ACCESS_FINE_LOCATION) + AccessKind.LOCATION_SERVICES -> error("Location services require asynchronous inspection") AccessKind.BACKGROUND_LOCATION -> permissionGranted(Manifest.permission.ACCESS_BACKGROUND_LOCATION) - AccessKind.NOTIFICATIONS -> permissionGranted(Manifest.permission.POST_NOTIFICATIONS) + AccessKind.NOTIFICATIONS -> notificationsEnabled(notificationFeatures) AccessKind.USAGE_ACCESS -> usageAccessGranted() AccessKind.RESEARCH_KEYBOARD_ENABLED -> keyboardId() in enabledKeyboardIds() AccessKind.RESEARCH_KEYBOARD_SELECTED -> keyboardId() == selectedKeyboardId() @@ -73,6 +179,38 @@ class AccessManager( private fun permissionGranted(permission: String): Boolean = applicationContext.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED + private fun notificationsEnabled(features: Set): Boolean { + require(features.isNotEmpty()) { "Notifications access requires at least one feature" } + if (!permissionGranted(Manifest.permission.POST_NOTIFICATIONS)) return false + val manager = applicationContext.getSystemService(NotificationManager::class.java) + return manager.areNotificationsEnabled() && notificationChannelIds( + features, + notificationChannelIdsByFeature, + ).all { channelId -> + val channel = manager.getNotificationChannel(channelId) + channel != null && channel.importance != NotificationManager.IMPORTANCE_NONE + } + } + + private fun settingsRequired(kind: AccessKind): Boolean = + permissionState.getBoolean(settingsRequiredKey(kind), false) + + private fun settingsRequiredKey(kind: AccessKind) = "settings-required-${kind.name}" + + private fun runtimePermission(kind: AccessKind): String? = when (kind) { + AccessKind.FINE_LOCATION -> Manifest.permission.ACCESS_FINE_LOCATION + AccessKind.NOTIFICATIONS -> Manifest.permission.POST_NOTIFICATIONS + AccessKind.LOCATION_SERVICES, + AccessKind.BACKGROUND_LOCATION, + AccessKind.USAGE_ACCESS, + AccessKind.RESEARCH_KEYBOARD_ENABLED, + AccessKind.RESEARCH_KEYBOARD_SELECTED, + AccessKind.ACCELEROMETER_HARDWARE, + AccessKind.GYROSCOPE_HARDWARE, + AccessKind.AMBIENT_LIGHT_HARDWARE, + AccessKind.PROXIMITY_HARDWARE -> null + } + @Suppress("DEPRECATION") private fun usageAccessGranted(): Boolean { val appOps = applicationContext.getSystemService(AppOpsManager::class.java) @@ -97,15 +235,7 @@ class AccessManager( .enabledInputMethodList .mapTo(mutableSetOf()) { it.id } - private fun selectedKeyboardId(): String? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { - inputMethodManager().currentInputMethodInfo?.id - } else { - @Suppress("DEPRECATION") - Settings.Secure.getString( - applicationContext.contentResolver, - Settings.Secure.DEFAULT_INPUT_METHOD, - ) - } + private fun selectedKeyboardId(): String? = inputMethodManager().currentInputMethodInfo?.id private fun inputMethodManager(): InputMethodManager = applicationContext.getSystemService(InputMethodManager::class.java) @@ -114,4 +244,25 @@ class AccessManager( applicationContext.packageName, keyboardServiceClassName, ).flattenToShortString() + + private companion object { + const val PERMISSION_STATE_PREFERENCES = "particeps-access-state-v1" + } } + +internal fun validatedNotificationChannelIds( + channels: Map, +): Map = channels.toMap().also { snapshot -> + require(snapshot.keys == NotificationAccessFeature.entries.toSet()) { + "Every notification feature must have exactly one app-owned channel" + } + require(snapshot.values.all(String::isNotBlank)) { "Notification channel IDs must not be blank" } + require(snapshot.values.distinct().size == snapshot.size) { + "Notification features must use distinct channels" + } +} + +internal fun notificationChannelIds( + features: Set, + channels: Map, +): Set = features.mapTo(mutableSetOf(), channels::getValue) diff --git a/core/access/src/main/kotlin/cool/jacoblin/particeps/core/access/AccessRules.kt b/core/access/src/main/kotlin/cool/jacoblin/particeps/core/access/AccessRules.kt new file mode 100644 index 0000000..899634e --- /dev/null +++ b/core/access/src/main/kotlin/cool/jacoblin/particeps/core/access/AccessRules.kt @@ -0,0 +1,152 @@ +package cool.jacoblin.particeps.core.access + +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.collector.AccessResolution +import cool.jacoblin.particeps.core.collector.AccessSnapshot +import cool.jacoblin.particeps.core.collector.AccessStatus +import cool.jacoblin.particeps.core.collector.AccessUnavailableReason +import cool.jacoblin.particeps.core.collector.SetupAction +import cool.jacoblin.particeps.core.collector.SetupGuidance + +internal data class AccessRule( + val order: Int, + val prerequisites: Set = emptySet(), + val action: SetupAction? = null, + val guidance: SetupGuidance? = null, +) + +internal data class AccessRuleOverride( + val action: SetupAction, + val guidance: SetupGuidance?, +) + +/** + * The closed Android acquisition contract for every access capability collectors may declare. + * + * Collectors name capabilities only. They cannot provide permission strings, intents, UI text, or + * callbacks. Keeping those operations here makes the platform behaviour exhaustive and auditable. + */ +internal object AccessRules { + val byKind: Map = mapOf( + AccessKind.NOTIFICATIONS to AccessRule( + order = 0, + action = SetupAction.RuntimePermission.NOTIFICATIONS, + ), + AccessKind.FINE_LOCATION to AccessRule( + order = 10, + action = SetupAction.RuntimePermission.FOREGROUND_LOCATION, + ), + AccessKind.LOCATION_SERVICES to AccessRule( + order = 11, + prerequisites = setOf(AccessKind.FINE_LOCATION), + action = SetupAction.SystemSettings.LOCATION_SERVICES, + guidance = SetupGuidance.LOCATION_SERVICES, + ), + AccessKind.BACKGROUND_LOCATION to AccessRule( + order = 12, + prerequisites = setOf(AccessKind.FINE_LOCATION, AccessKind.LOCATION_SERVICES), + action = SetupAction.SystemSettings.APPLICATION_DETAILS, + guidance = SetupGuidance.BACKGROUND_LOCATION, + ), + AccessKind.USAGE_ACCESS to AccessRule( + order = 20, + action = SetupAction.SystemSettings.USAGE_ACCESS, + guidance = SetupGuidance.USAGE_ACCESS, + ), + AccessKind.RESEARCH_KEYBOARD_ENABLED to AccessRule( + order = 30, + action = SetupAction.SystemSettings.INPUT_METHODS, + guidance = SetupGuidance.RESEARCH_KEYBOARD_ENABLE, + ), + AccessKind.RESEARCH_KEYBOARD_SELECTED to AccessRule( + order = 31, + prerequisites = setOf(AccessKind.RESEARCH_KEYBOARD_ENABLED), + action = SetupAction.ShowInputMethodPicker, + guidance = SetupGuidance.RESEARCH_KEYBOARD_SELECT, + ), + AccessKind.ACCELEROMETER_HARDWARE to AccessRule(order = 40), + AccessKind.GYROSCOPE_HARDWARE to AccessRule(order = 41), + AccessKind.AMBIENT_LIGHT_HARDWARE to AccessRule(order = 42), + AccessKind.PROXIMITY_HARDWARE to AccessRule(order = 43), + ) + + init { + require(byKind.keys == AccessKind.entries.toSet()) { "Every access kind must have exactly one rule" } + require(byKind.values.map(AccessRule::order).distinct().size == byKind.size) { + "Access setup order values must be unique" + } + byKind.forEach { (kind, rule) -> + require(kind !in rule.prerequisites) { "$kind cannot depend on itself" } + require(rule.prerequisites.all(byKind::containsKey)) { "$kind has an unknown prerequisite" } + } + ordered(AccessKind.entries.map { AccessRequirement(it, required = false) }.toSet()) + } + + fun ordered(requirements: Set): List { + val byRequiredKind = requirements.associateBy(AccessRequirement::kind) + require(byRequiredKind.size == requirements.size) { "Duplicate access requirements" } + requirements.forEach { requirement -> + val missing = byKind.getValue(requirement.kind).prerequisites - byRequiredKind.keys + require(missing.isEmpty()) { + "${requirement.kind} requires undeclared access: ${missing.sortedBy(AccessKind::name)}" + } + } + + val remaining = byRequiredKind.toMutableMap() + val completed = mutableSetOf() + val result = mutableListOf() + while (remaining.isNotEmpty()) { + val next = remaining.values + .filter { requirement -> byKind.getValue(requirement.kind).prerequisites.all(completed::contains) } + .minByOrNull { requirement -> byKind.getValue(requirement.kind).order } + ?: error("Access prerequisite graph contains a cycle") + result += next + completed += next.kind + remaining -= next.kind + } + return result + } + + fun resolve( + requirements: Set, + satisfiedKinds: Set, + unavailableSettings: Set, + actionOverrides: Map = emptyMap(), + unavailableKinds: Map = emptyMap(), + ): AccessSnapshot { + require(satisfiedKinds.all { kind -> requirements.any { it.kind == kind } }) { + "Satisfied access must be part of the requested requirements" + } + val requestedKinds = requirements.mapTo(mutableSetOf(), AccessRequirement::kind) + require(actionOverrides.keys.all(requestedKinds::contains)) { + "Access action overrides must be part of the requested requirements" + } + require(unavailableKinds.keys.all(requestedKinds::contains)) { + "Unavailable access must be part of the requested requirements" + } + require((actionOverrides.keys intersect unavailableKinds.keys).isEmpty()) { + "Access cannot be both actionable and unavailable" + } + val statuses = ordered(requirements).map { requirement -> + val rule = byKind.getValue(requirement.kind) + val override = actionOverrides[requirement.kind] + val action = override?.action ?: rule.action + val guidance = override?.guidance ?: rule.guidance + val missingPrerequisites = rule.prerequisites - satisfiedKinds + val resolution = when { + requirement.kind in satisfiedKinds -> AccessResolution.Satisfied + missingPrerequisites.isNotEmpty() -> + AccessResolution.BlockedByPrerequisites(missingPrerequisites) + requirement.kind in unavailableKinds -> + AccessResolution.Unavailable(unavailableKinds.getValue(requirement.kind)) + action == null -> AccessResolution.Unavailable(AccessUnavailableReason.HARDWARE_ABSENT) + action is SetupAction.SystemSettings && action in unavailableSettings -> + AccessResolution.Unavailable(AccessUnavailableReason.SYSTEM_HANDLER_MISSING) + else -> AccessResolution.ActionRequired(action) + } + AccessStatus(requirement, resolution, guidance) + } + return AccessSnapshot(statuses) + } +} diff --git a/core/access/src/main/kotlin/cool/jacoblin/particeps/core/access/LocationSettingsProbe.kt b/core/access/src/main/kotlin/cool/jacoblin/particeps/core/access/LocationSettingsProbe.kt new file mode 100644 index 0000000..83f41d5 --- /dev/null +++ b/core/access/src/main/kotlin/cool/jacoblin/particeps/core/access/LocationSettingsProbe.kt @@ -0,0 +1,85 @@ +package cool.jacoblin.particeps.core.access + +import android.content.Context +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.common.api.CommonStatusCodes +import com.google.android.gms.common.api.ResolvableApiException +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.LocationSettingsRequest +import com.google.android.gms.location.LocationSettingsStatusCodes +import com.google.android.gms.location.Priority +import com.google.android.gms.location.SettingsClient +import cool.jacoblin.particeps.core.collector.LocationAccessProfile +import cool.jacoblin.particeps.core.definition.LocationPriority +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withTimeoutOrNull + +enum class LocationSettingsProbeResult { + READY, + RESOLUTION_REQUIRED, + CHANGE_UNAVAILABLE, + CHECK_FAILED, +} + +fun interface LocationSettingsProbe { + suspend fun inspect(profile: LocationAccessProfile): LocationSettingsProbeResult +} + +/** Checks the configured Fused Location Provider request without exporting its resolution intent. */ +class GooglePlayLocationSettingsProbe internal constructor( + private val checkLocationSettings: suspend (LocationAccessProfile) -> Unit, +) : LocationSettingsProbe { + constructor(context: Context) : this( + LocationServices.getSettingsClient(context.applicationContext), + ) + + internal constructor(settingsClient: SettingsClient) : this( + checkLocationSettings = { profile -> + val request = LocationSettingsRequest.Builder() + .addLocationRequest(profile.toGooglePlayLocationRequest()) + .build() + settingsClient.checkLocationSettings(request).await() + }, + ) + + override suspend fun inspect(profile: LocationAccessProfile): LocationSettingsProbeResult = try { + val completed = withTimeoutOrNull(LOCATION_SETTINGS_CHECK_TIMEOUT_MILLIS) { + checkLocationSettings(profile) + true + } ?: false + if (completed) LocationSettingsProbeResult.READY else LocationSettingsProbeResult.CHECK_FAILED + } catch (failure: CancellationException) { + throw failure + } catch (failure: Exception) { + failure.toProbeResult() + } +} + +internal const val LOCATION_SETTINGS_CHECK_TIMEOUT_MILLIS = 5_000L + +internal fun LocationAccessProfile.toGooglePlayLocationRequest(): LocationRequest = LocationRequest.Builder( + priority.toGooglePlayPriority(), + intervalMillis, +) + .setMinUpdateIntervalMillis(minimumIntervalMillis) + .setMaxUpdateDelayMillis(maximumBatchDelayMillis) + .setMinUpdateDistanceMeters(minimumDisplacementMeters()) + .build() + +internal fun LocationAccessProfile.minimumDisplacementMeters(): Float = + minimumDisplacementMillimeters / 1_000f + +internal fun LocationPriority.toGooglePlayPriority(): Int = when (this) { + LocationPriority.BALANCED -> Priority.PRIORITY_BALANCED_POWER_ACCURACY + LocationPriority.HIGH_ACCURACY -> Priority.PRIORITY_HIGH_ACCURACY +} + +private fun Exception.toProbeResult(): LocationSettingsProbeResult = when { + this is ResolvableApiException && statusCode == CommonStatusCodes.RESOLUTION_REQUIRED -> + LocationSettingsProbeResult.RESOLUTION_REQUIRED + this is ApiException && statusCode == LocationSettingsStatusCodes.SETTINGS_CHANGE_UNAVAILABLE -> + LocationSettingsProbeResult.CHANGE_UNAVAILABLE + else -> LocationSettingsProbeResult.CHECK_FAILED +} diff --git a/core/access/src/test/kotlin/cool/jacoblin/particeps/core/access/AccessInspectionRequestTest.kt b/core/access/src/test/kotlin/cool/jacoblin/particeps/core/access/AccessInspectionRequestTest.kt new file mode 100644 index 0000000..ab2756b --- /dev/null +++ b/core/access/src/test/kotlin/cool/jacoblin/particeps/core/access/AccessInspectionRequestTest.kt @@ -0,0 +1,108 @@ +package cool.jacoblin.particeps.core.access + +import cool.jacoblin.particeps.core.collector.AccessInspectionRequest +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.collector.LocationAccessProfile +import cool.jacoblin.particeps.core.collector.NotificationAccessFeature +import cool.jacoblin.particeps.core.definition.LocationConfiguration +import cool.jacoblin.particeps.core.definition.LocationPriority +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class AccessInspectionRequestTest { + @Test + fun locationProfilePreservesEverySignedLocationRequestField() { + val configuration = LocationConfiguration( + required = true, + intervalMillis = 12_345, + minimumIntervalMillis = 2_345, + maximumBatchDelayMillis = 67_890, + minimumDisplacementMillimeters = 4_321, + priority = LocationPriority.HIGH_ACCURACY, + ) + + assertEquals( + LocationAccessProfile( + intervalMillis = 12_345, + minimumIntervalMillis = 2_345, + maximumBatchDelayMillis = 67_890, + minimumDisplacementMillimeters = 4_321, + priority = LocationPriority.HIGH_ACCURACY, + ), + LocationAccessProfile.from(configuration), + ) + } + + @Test + fun locationServicesRequiresAnExactProfileAndRejectsUnownedProfiles() { + assertThrows(IllegalArgumentException::class.java) { + AccessInspectionRequest( + requirements = setOf(required(AccessKind.LOCATION_SERVICES)), + ) + } + assertThrows(IllegalArgumentException::class.java) { + AccessInspectionRequest( + requirements = emptySet(), + locationProfile = locationProfile(), + ) + } + } + + @Test + fun notificationFeaturesAreClosedAndInterventionsRemainStudySpecific() { + val baseFeatures = setOf( + NotificationAccessFeature.COLLECTION, + NotificationAccessFeature.DAILY_STATUS, + ) + assertEquals( + baseFeatures, + AccessInspectionRequest( + requirements = setOf(required(AccessKind.NOTIFICATIONS)), + notificationFeatures = baseFeatures, + ).notificationFeatures, + ) + assertEquals( + NotificationAccessFeature.entries.toSet(), + AccessInspectionRequest( + requirements = setOf(required(AccessKind.NOTIFICATIONS)), + notificationFeatures = NotificationAccessFeature.entries.toSet(), + ).notificationFeatures, + ) + assertThrows(IllegalArgumentException::class.java) { + AccessInspectionRequest( + requirements = setOf(required(AccessKind.NOTIFICATIONS)), + notificationFeatures = setOf(NotificationAccessFeature.COLLECTION), + ) + } + assertThrows(IllegalArgumentException::class.java) { + AccessInspectionRequest( + requirements = emptySet(), + notificationFeatures = baseFeatures, + ) + } + } + + @Test + fun duplicateKindsWithConflictingRequirednessAreRejectedBeforePlatformInspection() { + assertThrows(IllegalArgumentException::class.java) { + AccessInspectionRequest( + requirements = setOf( + AccessRequirement(AccessKind.USAGE_ACCESS, required = true), + AccessRequirement(AccessKind.USAGE_ACCESS, required = false), + ), + ) + } + } + + private fun required(kind: AccessKind) = AccessRequirement(kind, required = true) + + private fun locationProfile() = LocationAccessProfile( + intervalMillis = 10_000, + minimumIntervalMillis = 5_000, + maximumBatchDelayMillis = 30_000, + minimumDisplacementMillimeters = 1_000, + priority = LocationPriority.BALANCED, + ) +} diff --git a/core/access/src/test/kotlin/cool/jacoblin/particeps/core/access/AccessRulesTest.kt b/core/access/src/test/kotlin/cool/jacoblin/particeps/core/access/AccessRulesTest.kt new file mode 100644 index 0000000..cd26a69 --- /dev/null +++ b/core/access/src/test/kotlin/cool/jacoblin/particeps/core/access/AccessRulesTest.kt @@ -0,0 +1,142 @@ +package cool.jacoblin.particeps.core.access + +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.collector.AccessResolution +import cool.jacoblin.particeps.core.collector.AccessSnapshot +import cool.jacoblin.particeps.core.collector.AccessUnavailableReason +import cool.jacoblin.particeps.core.collector.SetupAction +import cool.jacoblin.particeps.core.collector.SetupGuidance +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AccessRulesTest { + @Test + fun everyAccessKindHasOneClosedRuleAndDependencyGraphOrdersCompositeFlows() { + assertEquals(AccessKind.entries.toSet(), AccessRules.byKind.keys) + + val requirements = AccessKind.entries + .mapTo(mutableSetOf()) { AccessRequirement(it, required = true) } + val ordered = AccessRules.ordered(requirements).map(AccessRequirement::kind) + + assertTrue(ordered.indexOf(AccessKind.FINE_LOCATION) < ordered.indexOf(AccessKind.BACKGROUND_LOCATION)) + assertTrue( + ordered.indexOf(AccessKind.RESEARCH_KEYBOARD_ENABLED) < + ordered.indexOf(AccessKind.RESEARCH_KEYBOARD_SELECTED), + ) + } + + @Test + fun backgroundLocationIsBlockedUntilForegroundLocationIsSatisfied() { + val requirements = setOf( + AccessRequirement(AccessKind.FINE_LOCATION, required = true), + AccessRequirement(AccessKind.LOCATION_SERVICES, required = true), + AccessRequirement(AccessKind.BACKGROUND_LOCATION, required = true), + ) + + val initial = AccessRules.resolve(requirements, emptySet(), emptySet()) + assertEquals( + AccessResolution.ActionRequired(SetupAction.RuntimePermission.FOREGROUND_LOCATION), + initial.status(AccessKind.FINE_LOCATION).resolution, + ) + assertEquals( + AccessResolution.BlockedByPrerequisites( + setOf(AccessKind.FINE_LOCATION, AccessKind.LOCATION_SERVICES), + ), + initial.status(AccessKind.BACKGROUND_LOCATION).resolution, + ) + assertEquals( + SetupGuidance.BACKGROUND_LOCATION, + initial.status(AccessKind.BACKGROUND_LOCATION).guidance, + ) + + val foregroundGranted = AccessRules.resolve( + requirements, + setOf(AccessKind.FINE_LOCATION), + emptySet(), + ) + assertEquals( + AccessResolution.ActionRequired(SetupAction.SystemSettings.LOCATION_SERVICES), + foregroundGranted.status(AccessKind.LOCATION_SERVICES).resolution, + ) + assertEquals( + AccessResolution.BlockedByPrerequisites(setOf(AccessKind.LOCATION_SERVICES)), + foregroundGranted.status(AccessKind.BACKGROUND_LOCATION).resolution, + ) + + val locationServicesGranted = AccessRules.resolve( + requirements, + setOf(AccessKind.FINE_LOCATION, AccessKind.LOCATION_SERVICES), + emptySet(), + ) + assertEquals( + AccessResolution.ActionRequired(SetupAction.SystemSettings.APPLICATION_DETAILS), + locationServicesGranted.status(AccessKind.BACKGROUND_LOCATION).resolution, + ) + } + + @Test + fun fixedRuntimeDenialUsesOnlyTheDeclaredSettingsActionAndGuidance() { + val requirement = AccessRequirement(AccessKind.NOTIFICATIONS, required = true) + val override = AccessRuleOverride( + SetupAction.SystemSettings.APPLICATION_NOTIFICATIONS, + SetupGuidance.NOTIFICATIONS_SETTINGS, + ) + + val snapshot = AccessRules.resolve( + requirements = setOf(requirement), + satisfiedKinds = emptySet(), + unavailableSettings = emptySet(), + actionOverrides = mapOf(AccessKind.NOTIFICATIONS to override), + ) + + assertEquals( + AccessResolution.ActionRequired(SetupAction.SystemSettings.APPLICATION_NOTIFICATIONS), + snapshot.status(AccessKind.NOTIFICATIONS).resolution, + ) + assertEquals(SetupGuidance.NOTIFICATIONS_SETTINGS, snapshot.status(AccessKind.NOTIFICATIONS).guidance) + } + + @Test + fun missingSystemHandlerIsExplicitAndHasNoFallbackAction() { + val requirements = setOf(AccessRequirement(AccessKind.USAGE_ACCESS, required = true)) + val snapshot = AccessRules.resolve( + requirements, + satisfiedKinds = emptySet(), + unavailableSettings = setOf(SetupAction.SystemSettings.USAGE_ACCESS), + ) + + assertEquals( + AccessResolution.Unavailable(AccessUnavailableReason.SYSTEM_HANDLER_MISSING), + snapshot.status(AccessKind.USAGE_ACCESS).resolution, + ) + } + + @Test + fun locationProbeFailuresAreUnavailableAndNeverFallBackToSettingsActions() { + val requirements = setOf( + AccessRequirement(AccessKind.FINE_LOCATION, required = true), + AccessRequirement(AccessKind.LOCATION_SERVICES, required = true), + ) + listOf( + AccessUnavailableReason.LOCATION_SETTINGS_CHANGE_UNAVAILABLE, + AccessUnavailableReason.LOCATION_SETTINGS_CHECK_FAILED, + ).forEach { reason -> + val snapshot = AccessRules.resolve( + requirements = requirements, + satisfiedKinds = setOf(AccessKind.FINE_LOCATION), + unavailableSettings = emptySet(), + unavailableKinds = mapOf(AccessKind.LOCATION_SERVICES to reason), + ) + + assertEquals( + AccessResolution.Unavailable(reason), + snapshot.status(AccessKind.LOCATION_SERVICES).resolution, + ) + } + } + + private fun AccessSnapshot.status(kind: AccessKind) = + statuses.single { it.requirement.kind == kind } +} diff --git a/core/access/src/test/kotlin/cool/jacoblin/particeps/core/access/GooglePlayLocationSettingsProbeTest.kt b/core/access/src/test/kotlin/cool/jacoblin/particeps/core/access/GooglePlayLocationSettingsProbeTest.kt new file mode 100644 index 0000000..06b91f3 --- /dev/null +++ b/core/access/src/test/kotlin/cool/jacoblin/particeps/core/access/GooglePlayLocationSettingsProbeTest.kt @@ -0,0 +1,110 @@ +package cool.jacoblin.particeps.core.access + +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.common.api.CommonStatusCodes +import com.google.android.gms.common.api.ResolvableApiException +import com.google.android.gms.common.api.Status +import com.google.android.gms.location.LocationSettingsStatusCodes +import com.google.android.gms.location.Priority +import cool.jacoblin.particeps.core.collector.LocationAccessProfile +import cool.jacoblin.particeps.core.definition.LocationPriority +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test + +class GooglePlayLocationSettingsProbeTest { + @Test + fun prioritiesMatchTheCollectorLocationRequestContract() { + assertEquals( + Priority.PRIORITY_BALANCED_POWER_ACCURACY, + LocationPriority.BALANCED.toGooglePlayPriority(), + ) + assertEquals( + Priority.PRIORITY_HIGH_ACCURACY, + LocationPriority.HIGH_ACCURACY.toGooglePlayPriority(), + ) + assertEquals(1.25f, profile().minimumDisplacementMeters(), 0f) + } + + @Test + fun successfulCheckIsReadyAndReceivesTheExactProfile() = runTest { + val expected = profile() + var received: LocationAccessProfile? = null + val probe = GooglePlayLocationSettingsProbe { actual -> received = actual } + + assertEquals(LocationSettingsProbeResult.READY, probe.inspect(expected)) + assertEquals(expected, received) + } + + @Test + fun onlyResolvableApiExceptionIsActionable() = runTest { + val resolvable = ResolvableApiException(Status(CommonStatusCodes.RESOLUTION_REQUIRED)) + assertEquals( + LocationSettingsProbeResult.RESOLUTION_REQUIRED, + failingProbe(resolvable).inspect(profile()), + ) + + val statusCodeWithoutResolution = ApiException(Status(CommonStatusCodes.RESOLUTION_REQUIRED)) + assertEquals( + LocationSettingsProbeResult.CHECK_FAILED, + failingProbe(statusCodeWithoutResolution).inspect(profile()), + ) + } + + @Test + fun unchangeableAndUnexpectedFailuresRemainDistinctAndFailClosed() = runTest { + assertEquals( + LocationSettingsProbeResult.CHANGE_UNAVAILABLE, + failingProbe( + ApiException(Status(LocationSettingsStatusCodes.SETTINGS_CHANGE_UNAVAILABLE)), + ).inspect(profile()), + ) + assertEquals( + LocationSettingsProbeResult.CHECK_FAILED, + failingProbe(ApiException(Status.RESULT_INTERNAL_ERROR)).inspect(profile()), + ) + assertEquals( + LocationSettingsProbeResult.CHECK_FAILED, + failingProbe(IllegalStateException("Play services check failed")).inspect(profile()), + ) + } + + @Test + fun coroutineCancellationIsNeverConvertedIntoCheckFailure() = runTest { + val cancellation = CancellationException("test cancellation") + try { + failingProbe(cancellation).inspect(profile()) + fail("CancellationException must be rethrown") + } catch (caught: CancellationException) { + // Coroutine stack-trace recovery may copy a CancellationException; the contract is + // propagation rather than object identity. + assertEquals(cancellation.message, caught.message) + } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun aCheckThatNeverCompletesFailsClosedAtTheBoundedDeadline() = runTest { + val probe = GooglePlayLocationSettingsProbe { awaitCancellation() } + + assertEquals(LocationSettingsProbeResult.CHECK_FAILED, probe.inspect(profile())) + assertEquals(LOCATION_SETTINGS_CHECK_TIMEOUT_MILLIS, currentTime) + } + + private fun failingProbe(failure: Exception) = GooglePlayLocationSettingsProbe { + throw failure + } + + private fun profile() = LocationAccessProfile( + intervalMillis = 10_000, + minimumIntervalMillis = 2_000, + maximumBatchDelayMillis = 60_000, + minimumDisplacementMillimeters = 1_250, + priority = LocationPriority.BALANCED, + ) +} diff --git a/core/access/src/test/kotlin/cool/jacoblin/particeps/core/access/NotificationChannelSelectionTest.kt b/core/access/src/test/kotlin/cool/jacoblin/particeps/core/access/NotificationChannelSelectionTest.kt new file mode 100644 index 0000000..a84f7f4 --- /dev/null +++ b/core/access/src/test/kotlin/cool/jacoblin/particeps/core/access/NotificationChannelSelectionTest.kt @@ -0,0 +1,59 @@ +package cool.jacoblin.particeps.core.access + +import cool.jacoblin.particeps.core.collector.NotificationAccessFeature +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class NotificationChannelSelectionTest { + @Test + fun studiesWithoutInterventionsInspectOnlyCollectionAndDailyChannels() { + assertEquals( + setOf("collection", "daily"), + notificationChannelIds( + features = setOf( + NotificationAccessFeature.COLLECTION, + NotificationAccessFeature.DAILY_STATUS, + ), + channels = channels(), + ), + ) + } + + @Test + fun interventionStudiesAddOnlyTheClosedInterventionChannel() { + assertEquals( + setOf("collection", "daily", "interventions"), + notificationChannelIds( + features = NotificationAccessFeature.entries.toSet(), + channels = channels(), + ), + ) + } + + @Test + fun appOwnedChannelMapMustBeExhaustiveNonBlankAndOneToOne() { + assertEquals(channels(), validatedNotificationChannelIds(channels())) + assertThrows(IllegalArgumentException::class.java) { + validatedNotificationChannelIds( + channels() - NotificationAccessFeature.INTERVENTIONS, + ) + } + assertThrows(IllegalArgumentException::class.java) { + validatedNotificationChannelIds( + channels() + (NotificationAccessFeature.INTERVENTIONS to ""), + ) + } + assertThrows(IllegalArgumentException::class.java) { + validatedNotificationChannelIds( + channels() + (NotificationAccessFeature.INTERVENTIONS to "daily"), + ) + } + } + + private fun channels() = mapOf( + NotificationAccessFeature.COLLECTION to "collection", + NotificationAccessFeature.DAILY_STATUS to "daily", + NotificationAccessFeature.INTERVENTIONS to "interventions", + ) +} diff --git a/core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/CollectorContracts.kt b/core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/CollectorContracts.kt index 15af5b7..1bbdf8f 100644 --- a/core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/CollectorContracts.kt +++ b/core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/CollectorContracts.kt @@ -1,8 +1,10 @@ package cool.jacoblin.particeps.core.collector +import cool.jacoblin.particeps.core.definition.CollectorConfiguration +import cool.jacoblin.particeps.core.definition.LocationConfiguration +import cool.jacoblin.particeps.core.definition.LocationPriority import cool.jacoblin.particeps.core.model.EventDraft import cool.jacoblin.particeps.core.model.RecordedEvent -import cool.jacoblin.particeps.core.definition.CollectorConfiguration import com.google.gson.JsonParser import com.google.gson.JsonParseException import com.google.gson.Strictness @@ -19,6 +21,7 @@ enum class PrivacyClass { enum class AccessKind { FINE_LOCATION, + LOCATION_SERVICES, BACKGROUND_LOCATION, NOTIFICATIONS, USAGE_ACCESS, @@ -35,20 +38,168 @@ data class AccessRequirement( val required: Boolean, ) +/** App-owned notification purposes derived from verified study features; channel IDs stay platform-owned. */ +enum class NotificationAccessFeature { + COLLECTION, + DAILY_STATUS, + INTERVENTIONS, +} + +/** + * The exact location request configured by the study and checked before collection starts. + * + * This deliberately preserves the signed integer units. The Android implementation performs the + * same millimetre-to-metre conversion as the collector when it builds the Play services request. + */ +data class LocationAccessProfile( + val intervalMillis: Long, + val minimumIntervalMillis: Long, + val maximumBatchDelayMillis: Long, + val minimumDisplacementMillimeters: Int, + val priority: LocationPriority, +) { + init { + require(intervalMillis in 1_000..3_600_000) { "Invalid location interval" } + require(minimumIntervalMillis in 500..intervalMillis) { "Invalid location minimum interval" } + require(maximumBatchDelayMillis in 0..86_400_000) { "Invalid location batch delay" } + require(minimumDisplacementMillimeters in 0..10_000_000) { "Invalid location displacement" } + } + + companion object { + fun from(configuration: LocationConfiguration) = LocationAccessProfile( + intervalMillis = configuration.intervalMillis, + minimumIntervalMillis = configuration.minimumIntervalMillis, + maximumBatchDelayMillis = configuration.maximumBatchDelayMillis, + minimumDisplacementMillimeters = configuration.minimumDisplacementMillimeters, + priority = configuration.priority, + ) + } +} + +/** + * All context needed to inspect access without guessing collector configuration or channel IDs. + */ +data class AccessInspectionRequest( + val requirements: Set, + val locationProfile: LocationAccessProfile? = null, + val notificationFeatures: Set = emptySet(), +) { + init { + require(requirements.distinctBy(AccessRequirement::kind).size == requirements.size) { + "Access inspection contains duplicate kinds" + } + require((AccessKind.LOCATION_SERVICES in requirements.map(AccessRequirement::kind)) == (locationProfile != null)) { + "Location services access requires exactly one location profile" + } + val requestsNotifications = requirements.any { it.kind == AccessKind.NOTIFICATIONS } + require(requestsNotifications || notificationFeatures.isEmpty()) { + "Notification features require notifications access" + } + require(!requestsNotifications || notificationFeatures.containsAll(BASE_NOTIFICATION_FEATURES)) { + "Notifications access requires collection and daily-status channels" + } + } + + private companion object { + val BASE_NOTIFICATION_FEATURES = setOf( + NotificationAccessFeature.COLLECTION, + NotificationAccessFeature.DAILY_STATUS, + ) + } +} + +sealed interface SetupAction { + enum class RuntimePermission : SetupAction { + FOREGROUND_LOCATION, + NOTIFICATIONS, + } + + enum class SystemSettings : SetupAction { + APPLICATION_DETAILS, + APPLICATION_NOTIFICATIONS, + LOCATION_SERVICES, + USAGE_ACCESS, + INPUT_METHODS, + } + + data object ShowInputMethodPicker : SetupAction +} + +enum class SetupGuidance { + FOREGROUND_LOCATION_SETTINGS, + LOCATION_SERVICES, + BACKGROUND_LOCATION, + NOTIFICATIONS_SETTINGS, + USAGE_ACCESS, + RESEARCH_KEYBOARD_ENABLE, + RESEARCH_KEYBOARD_SELECT, +} + +enum class AccessUnavailableReason { + HARDWARE_ABSENT, + LOCATION_SETTINGS_CHANGE_UNAVAILABLE, + LOCATION_SETTINGS_CHECK_FAILED, + SYSTEM_HANDLER_MISSING, +} + +sealed interface AccessResolution { + data object Satisfied : AccessResolution + + data class ActionRequired( + val action: SetupAction, + ) : AccessResolution + + data class BlockedByPrerequisites( + val missing: Set, + ) : AccessResolution { + init { + require(missing.isNotEmpty()) { "Blocked access must name a missing prerequisite" } + } + } + + data class Unavailable( + val reason: AccessUnavailableReason, + ) : AccessResolution +} + data class AccessStatus( val requirement: AccessRequirement, - val granted: Boolean, -) + val resolution: AccessResolution, + val guidance: SetupGuidance?, +) { + val granted: Boolean get() = resolution == AccessResolution.Satisfied +} + +data class AccessSnapshot( + val statuses: List, +) { + val satisfiedKinds: Set = statuses + .filter(AccessStatus::granted) + .mapTo(mutableSetOf()) { it.requirement.kind } + val requiredReady: Boolean = statuses.none { it.requirement.required && !it.granted } + + init { + require(statuses.distinctBy { it.requirement.kind }.size == statuses.size) { + "Access snapshot contains duplicate kinds" + } + } +} + interface StudyAccessGateway { - fun inspect(requirements: Set): List - fun grantedKinds(requirements: Set): Set + suspend fun inspect(request: AccessInspectionRequest): AccessSnapshot } +data class CollectorAccessRequirement( + val collectorId: String, + val requirement: AccessRequirement, +) + data class CollectorDescriptor( val id: String, val displayName: String, val privacyClass: PrivacyClass, val eventContract: CollectorEventContract, + val accessKinds: Set, ) { val payloadSchemaVersion get() = eventContract.payloadSchemaVersion val maximumEncodedEventBytes get() = eventContract.maximumEncodedEventBytes @@ -58,6 +209,9 @@ data class CollectorDescriptor( require(displayName.isNotBlank()) { "Collector display name must not be blank" } } + fun accessRequirements(required: Boolean): Set = + accessKinds.mapTo(mutableSetOf()) { kind -> AccessRequirement(kind, required) } + private companion object { val ID_PATTERN = Regex("[a-z][a-z0-9_.-]{2,63}") } @@ -297,8 +451,6 @@ interface ResearchClocks { interface CollectorPlugin { val descriptor: CollectorDescriptor - fun accessRequirements(configuration: CollectorConfiguration): Set - fun create(configuration: CollectorConfiguration, context: CollectorContext): Collector } @@ -310,6 +462,13 @@ interface Collector { suspend fun start() + /** + * Called after source startup succeeds and the runtime opens this collector's admission gate. + * Collectors that publish an initial snapshot must do so here: callbacks delivered while the + * source is still registering are intentionally outside the admitted collection interval. + */ + suspend fun onAdmissionOpened() = Unit + suspend fun pause() suspend fun resume() @@ -335,8 +494,11 @@ class CollectorRegistry( plugins.singleOrNull { it.descriptor.id == configuration.id } ?: throw IllegalArgumentException("Collector is not compiled into this app: ${configuration.id}") - fun accessRequirements(configurations: List): Set = + fun accessRequirements(configurations: List): List = configurations.flatMap { configuration -> - pluginFor(configuration).accessRequirements(configuration) - }.toSet() + val descriptor = pluginFor(configuration).descriptor + descriptor.accessRequirements(configuration.required).map { requirement -> + CollectorAccessRequirement(descriptor.id, requirement) + } + } } diff --git a/core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/SerializedCallbackCollector.kt b/core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/SerializedCallbackCollector.kt index d0cd1a0..4d251e8 100644 --- a/core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/SerializedCallbackCollector.kt +++ b/core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/SerializedCallbackCollector.kt @@ -45,6 +45,12 @@ abstract class SerializedCallbackCollector( } } + final override suspend fun onAdmissionOpened() { + checkNotNull(consumerJob) { "Collector is not started" } + check(sourceState == SourceState.REGISTERED) { "Collector source is not registered" } + onSourceAdmitted() + } + final override suspend fun pause() { checkNotNull(consumerJob) { "Collector is not started" } val failure = runCatching { unregister() }.exceptionOrNull() @@ -115,6 +121,10 @@ abstract class SerializedCallbackCollector( } protected abstract suspend fun registerSource(): SourceRegistrationResult + + /** Publishes source state that must be observed once, after runtime admission is open. */ + protected open suspend fun onSourceAdmitted() = Unit + /** * Returns only when a fresh source generation is safe. An exception means physical teardown is * uncertain, so the base class deliberately keeps the logical registration and blocks resume. diff --git a/core/experiment-runtime/src/main/kotlin/cool/jacoblin/particeps/core/runtime/EventAdmissionGate.kt b/core/experiment-runtime/src/main/kotlin/cool/jacoblin/particeps/core/runtime/EventAdmissionGate.kt index 30d0c0d..76d702d 100644 --- a/core/experiment-runtime/src/main/kotlin/cool/jacoblin/particeps/core/runtime/EventAdmissionGate.kt +++ b/core/experiment-runtime/src/main/kotlin/cool/jacoblin/particeps/core/runtime/EventAdmissionGate.kt @@ -2,6 +2,7 @@ package cool.jacoblin.particeps.core.runtime import cool.jacoblin.particeps.core.collector.AdmissionToken internal class EventAdmissionGate { + private val identity = Any() private var epoch = 0L private var mode = Mode.CLOSED private var drainBoundaryElapsedNanos = Long.MIN_VALUE @@ -12,11 +13,11 @@ internal class EventAdmissionGate { epoch += 1 mode = Mode.ACTIVE drainBoundaryElapsedNanos = Long.MIN_VALUE - return EpochToken(epoch) + return EpochToken(identity, epoch) } @Synchronized - fun capture(): AdmissionToken? = if (mode == Mode.ACTIVE) EpochToken(epoch) else null + fun capture(): AdmissionToken? = if (mode == Mode.ACTIVE) EpochToken(identity, epoch) else null @Synchronized fun beginDrain(boundaryElapsedNanos: Long): AdmissionToken { @@ -24,14 +25,7 @@ internal class EventAdmissionGate { require(boundaryElapsedNanos >= 0) { "Drain boundary must be non-negative" } mode = Mode.DRAINING drainBoundaryElapsedNanos = boundaryElapsedNanos - return EpochToken(epoch) - } - - @Synchronized - fun restoreActive(token: AdmissionToken) { - check(token.epoch() == epoch && mode == Mode.DRAINING) { "Cannot restore a stale admission epoch" } - mode = Mode.ACTIVE - drainBoundaryElapsedNanos = Long.MIN_VALUE + return EpochToken(identity, epoch) } @Synchronized @@ -57,9 +51,15 @@ internal class EventAdmissionGate { drainBoundaryElapsedNanos = Long.MIN_VALUE } - private fun AdmissionToken.epoch(): Long = (this as? EpochToken)?.epoch ?: Long.MIN_VALUE + private fun AdmissionToken.epoch(): Long = (this as? EpochToken) + ?.takeIf { it.owner === identity } + ?.epoch + ?: Long.MIN_VALUE - private data class EpochToken(val epoch: Long) : AdmissionToken + private class EpochToken( + val owner: Any, + val epoch: Long, + ) : AdmissionToken private enum class Mode { CLOSED, diff --git a/core/experiment-runtime/src/main/kotlin/cool/jacoblin/particeps/core/runtime/ExperimentRuntime.kt b/core/experiment-runtime/src/main/kotlin/cool/jacoblin/particeps/core/runtime/ExperimentRuntime.kt index 9f4c137..8a32986 100644 --- a/core/experiment-runtime/src/main/kotlin/cool/jacoblin/particeps/core/runtime/ExperimentRuntime.kt +++ b/core/experiment-runtime/src/main/kotlin/cool/jacoblin/particeps/core/runtime/ExperimentRuntime.kt @@ -27,12 +27,18 @@ import cool.jacoblin.particeps.core.model.InterventionOccurrence import cool.jacoblin.particeps.core.model.OccurrenceState import cool.jacoblin.particeps.core.model.ExperimentStateMachine import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.SafetyPauseReason import cool.jacoblin.particeps.core.model.StudyMetadata import cool.jacoblin.particeps.core.model.StudyStore +import cool.jacoblin.particeps.core.model.StudyStoreMutationFailedClosed import cool.jacoblin.particeps.core.model.TransitionReason +import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -41,10 +47,13 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext data class RuntimeSnapshot( val metadata: StudyMetadata? = null, val collectorHealth: Map = emptyMap(), + /** Typed request retained until the application confirms a durable safety-pause handoff. */ + val pendingSafetyPauseReason: SafetyPauseReason? = null, val incidentCode: String? = null, ) @@ -94,18 +103,27 @@ sealed interface SurveyAnswer { enum class SurveySubmissionResult { ACCEPTED, ALREADY_SUBMITTED, EXPIRED, INVALID } +/** Persists a closed safety reason without acquiring any runtime or application session lock. */ +fun interface SafetyPauseWitness { + suspend fun persist(reason: SafetyPauseReason) +} + class ExperimentRuntime( val configuration: StudyConfiguration, private val store: StudyStore, private val collectorRegistry: CollectorRegistry, internal val clocks: ResearchClocks, private val scope: CoroutineScope, - private val availableAccess: () -> Set, -) : EventSink { + private val safetyPauseWitness: SafetyPauseWitness, +) { private val stateMachine = ExperimentStateMachine() private val admissionGate = EventAdmissionGate() + private val pendingSafetyPause = AtomicReference(null) private val commandMutex = Mutex() private val metadataMutex = Mutex() + private val admittedWriteMutex = Mutex() + private var admittedWriteCount = 0 + private var admittedWritesDrained = CompletableDeferred().apply { complete(Unit) } private val collectorEntries = mutableMapOf() private val healthJobs = mutableListOf() private var currentMetadata: StudyMetadata? = null @@ -115,15 +133,21 @@ class ExperimentRuntime( fun now() = clocks.now() - suspend fun initialize(): CommandResult = executeCommand(requireInitialized = false) { + /** Loads durable state and creates collectors without starting any process resource. */ + suspend fun initialize( + recoveredSafetyReason: SafetyPauseReason? = null, + ): CommandResult = executeCommand(requireInitialized = false) { check(currentMetadata == null) { "Runtime is already initialized" } - val loaded = store.loadMetadata() ?: StudyMetadata.initial( + var loaded = store.loadMetadata() ?: StudyMetadata.initial( configuration.experimentId, configuration.configurationId, configuration.assignedParticipantId, ).also { initial -> store.initialize(initial) } + store.resolvePendingAppendFailure( + (recoveredSafetyReason ?: SafetyPauseReason.STORAGE_FAILURE).transitionReason, + )?.let { resolved -> loaded = resolved } check(loaded.experimentId == configuration.experimentId) { "Experiment ID mismatch" } check(loaded.configurationId == configuration.configurationId) { "Configuration ID mismatch" } check(loaded.assignedParticipantId == configuration.assignedParticipantId) { "Assigned participant ID mismatch" } @@ -135,10 +159,30 @@ class ExperimentRuntime( collectorHealth = collectorEntries.mapValues { entry -> entry.value.collector.health.value }, ) } - if (loaded.state == ExperimentState.RUNNING) { - admissionGate.open() - activateCollectors() + } + + /** + * Restores collectors only after the application has confirmed its foreground-service host. + * + * Keeping this separate from [initialize] prevents a recovered location collector from + * acquiring resources before Android has acknowledged the matching foreground-service type. + */ + suspend fun activateRecoveredRunning(availableAccess: Set): CommandResult = executeCommand { + check(requireMetadata().state == ExperimentState.RUNNING) { + "Only a durably running study can be recovered" } + admissionGate.open() + activateCollectors(availableAccess) + } + + /** Converts a stale durable RUNNING state to its typed safety pause before activation. */ + suspend fun pauseRecoveredForSafetyFailure(reason: SafetyPauseReason): CommandResult = executeCommand { + check(requireMetadata().state == ExperimentState.RUNNING) { + "Only a durably running study can be paused during recovery" + } + closeAllAdmission() + val effectiveReason = latchSafetyPauseReason(reason) + transitionTo(ExperimentState.PAUSED, effectiveReason.transitionReason) } suspend fun reviewStudy(): CommandResult = executeCommand { @@ -159,7 +203,7 @@ class ExperimentRuntime( suspend fun completeAccessSetup(availableAccess: Set): CommandResult = executeCommand { val missingRequired = configuredPlugins() .flatMap { (collectorConfiguration, plugin) -> - plugin.accessRequirements(collectorConfiguration) + plugin.descriptor.accessRequirements(collectorConfiguration.required) } .filter { it.required && it.kind !in availableAccess } require(missingRequired.isEmpty()) { @@ -168,10 +212,10 @@ class ExperimentRuntime( transitionTo(ExperimentState.READY, TransitionReason.ACCESS_PREFLIGHT_PASSED) } - suspend fun start(): CommandResult = executeCommand { + suspend fun start(availableAccess: Set): CommandResult = executeCommand { transitionTo(ExperimentState.RUNNING, TransitionReason.PARTICIPANT_STARTED) admissionGate.open() - activateCollectors() + activateCollectors(availableAccess) } suspend fun pause(): CommandResult = executeCommand { @@ -182,10 +226,198 @@ class ExperimentRuntime( ) } - suspend fun resume(): CommandResult = executeCommand { + /** + * Closes every event boundary before durably recording a non-participant safety pause. + * + * Admission remains closed and collectors are paused even if the metadata write fails. The + * application owns a typed durable marker and retries this operation without losing [reason]. + */ + suspend fun closeAdmissionForSafetyFailure(reason: SafetyPauseReason): SafetyPauseReason? = + commandMutex.withLock { + if (currentMetadata?.state !in setOf( + ExperimentState.READY, + ExperimentState.RUNNING, + ExperimentState.PAUSED, + ) + ) { + return@withLock null + } + closeAllAdmission() + latchSafetyPauseReason(reason) + } + + suspend fun pauseForSafetyFailure(reason: SafetyPauseReason): CommandResult = commandMutex.withLock { + mutableSnapshot.update { it.copy(incidentCode = null) } + val metadata = currentMetadata + if (metadata?.state != ExperimentState.RUNNING) { + mutableSnapshot.update { it.copy(incidentCode = INCIDENT_COMMAND_REJECTED) } + return@withLock CommandResult.Failed(INCIDENT_COMMAND_REJECTED) + } + + closeAllAdmission() + val effectiveReason = latchSafetyPauseReason(reason) + val boundary = clocks.now() + + var transitionFailure: Throwable? = null + val collectorsPaused = withContext(NonCancellable) { + val paused = pauseCollectors() + awaitAdmittedWritesDrained() + paused + } + try { + val afterDrain = requireMetadata() + val alreadyCommitted = afterDrain.state == ExperimentState.PAUSED && + afterDrain.transitions.lastOrNull()?.reason == effectiveReason.transitionReason + if (!alreadyCommitted) { + transitionTo( + ExperimentState.PAUSED, + effectiveReason.transitionReason, + boundary, + ) + } + } catch (failure: Throwable) { + transitionFailure = failure + } + + transitionFailure?.let { failure -> + failure.rethrowIfCancellation() + mutableSnapshot.update { it.copy(incidentCode = INCIDENT_PAUSE_PERSISTENCE_FAILED) } + return@withLock CommandResult.Failed(INCIDENT_PAUSE_PERSISTENCE_FAILED) + } + if (!collectorsPaused) { + mutableSnapshot.update { it.copy(incidentCode = INCIDENT_COLLECTOR_PAUSE_FAILED) } + return@withLock CommandResult.Failed(INCIDENT_COLLECTOR_PAUSE_FAILED) + } + CommandResult.Success + } + + fun hasPendingSafetyPause(): Boolean { + val current = mutableSnapshot.value + return pendingSafetyPause.get() != null || + (current.metadata?.state == ExperimentState.RUNNING && + current.incidentCode == INCIDENT_PAUSE_PERSISTENCE_FAILED) + } + + /** + * Clears the runtime-owned signal only after the application established either the durable + * PAUSED boundary and cleanup or an acknowledged durable retry carrying the same closed reason. + */ + suspend fun acknowledgeSafetyPauseRequest(reason: SafetyPauseReason): Boolean = commandMutex.withLock { + if (!pendingSafetyPause.compareAndSet(reason, null)) return@withLock false + mutableSnapshot.update { current -> + if (current.pendingSafetyPauseReason == reason) { + current.copy(pendingSafetyPauseReason = null) + } else { + current + } + } + true + } + + /** Retries both the durable boundary and any collector teardown that did not complete. */ + suspend fun retrySafetyPause(reason: SafetyPauseReason): CommandResult = commandMutex.withLock { + closeAllAdmission() + val effectiveReason = latchSafetyPauseReason(reason) + val metadata = currentMetadata + val state = metadata?.state + if (state !in setOf(ExperimentState.READY, ExperimentState.RUNNING, ExperimentState.PAUSED)) { + return@withLock CommandResult.Failed(INCIDENT_COMMAND_REJECTED) + } + try { + val collectorsPaused = withContext(NonCancellable) { + val paused = pauseCollectors() + awaitAdmittedWritesDrained() + paused + } + if (state == ExperimentState.RUNNING) { + transitionTo(ExperimentState.PAUSED, effectiveReason.transitionReason) + } else { + val resolvedAppend = store.resolvePendingAppendFailure(effectiveReason.transitionReason) + // A failed READY/PAUSED -> RUNNING save may have atomically replaced the file before + // its directory-fsync or coroutine acknowledgement failed. Rewriting the verified + // in-memory pre-transition metadata is the rollback acknowledgement; the marker or + // typed work must remain until this write succeeds. + if (resolvedAppend == null) { + store.saveMetadata(requireNotNull(metadata)) + } else { + currentMetadata = resolvedAppend + publishMetadata(resolvedAppend) + } + } + if (!collectorsPaused) { + mutableSnapshot.update { it.copy(incidentCode = INCIDENT_COLLECTOR_PAUSE_FAILED) } + return@withLock CommandResult.Failed(INCIDENT_COLLECTOR_PAUSE_FAILED) + } + mutableSnapshot.update { it.copy(incidentCode = null) } + CommandResult.Success + } catch (failure: Throwable) { + failure.rethrowIfCancellation() + mutableSnapshot.update { it.copy(incidentCode = INCIDENT_PAUSE_PERSISTENCE_FAILED) } + CommandResult.Failed(INCIDENT_PAUSE_PERSISTENCE_FAILED) + } + } + + suspend fun resume(availableAccess: Set): CommandResult = executeCommand { + check(pendingSafetyPause.get() == null) { + "A pending safety failure must be durably resolved before resuming" + } transitionTo(ExperimentState.RUNNING, TransitionReason.PARTICIPANT_RESUMED) admissionGate.open() - activateCollectors() + activateCollectors(availableAccess) + } + + /** Reconciles optional collector access while the study itself remains running. */ + suspend fun reconcileCollectorAccess(availableKinds: Set): CommandResult = executeCommand { + check(requireMetadata().state == ExperimentState.RUNNING) { + "Collector access can be reconciled only while running" + } + check(pendingSafetyPause.get() == null) { + "Collector access cannot reopen while a safety pause is pending" + } + collectorEntries.forEach { (id, entry) -> + val missingAccess = entry.plugin.descriptor + .accessRequirements(entry.configuration.required) + .any { requirement -> requirement.kind !in availableKinds } + if (missingAccess) { + // Access revocation is the admission boundary. Close it before inspecting health or + // asking the source to tear down, because FAILED does not prove physical release. + entry.closeAdmission() + entry.accessBlocked = true + if (!entry.hasStarted) { + publishCollectorHealth(id, entry) + return@forEach + } + if (!entry.sourcePaused) { + try { + entry.collector.pause() + entry.sourcePaused = true + entry.admissionFailureReason = null + } catch (failure: Throwable) { + failure.rethrowIfCancellation() + entry.admissionFailureReason = "COLLECTOR_PAUSE_FAILED" + publishCollectorHealth(id, entry) + return@forEach + } + } + publishCollectorHealth(id, entry) + return@forEach + } + + entry.accessBlocked = false + when { + entry.admissionOpen -> publishCollectorHealth(id, entry) + !entry.hasStarted -> activateCollector(id, entry, resume = false) + entry.sourcePaused -> activateCollector(id, entry, resume = true) + else -> { + // A previous start or teardown did not establish a resumable source state. + // Access returning cannot make that uncertainty safe, so admission stays shut. + if (entry.admissionFailureReason == null) { + entry.admissionFailureReason = "COLLECTOR_ADMISSION_CLOSED" + } + publishCollectorHealth(id, entry) + } + } + } } suspend fun finishEarly(): CommandResult = executeCommand { @@ -197,8 +429,10 @@ class ExperimentRuntime( stopCollectors = true, ) } else { - transitionTo(ExperimentState.COMPLETED, TransitionReason.PARTICIPANT_FINISHED_EARLY) - stopCollectors() + stopAndTransitionFromClosed( + ExperimentState.COMPLETED, + TransitionReason.PARTICIPANT_FINISHED_EARLY, + ) } } @@ -211,8 +445,10 @@ class ExperimentRuntime( stopCollectors = true, ) } else { - transitionTo(ExperimentState.COMPLETED, TransitionReason.STUDY_DURATION_ELAPSED) - stopCollectors() + stopAndTransitionFromClosed( + ExperimentState.COMPLETED, + TransitionReason.STUDY_DURATION_ELAPSED, + ) } } @@ -225,16 +461,31 @@ class ExperimentRuntime( ) ExperimentState.PAUSED -> { - transitionTo(ExperimentState.WITHDRAWN, TransitionReason.PARTICIPANT_WITHDREW) - stopCollectors() + stopAndTransitionFromClosed( + ExperimentState.WITHDRAWN, + TransitionReason.PARTICIPANT_WITHDREW, + ) } - else -> transitionTo(ExperimentState.WITHDRAWN, TransitionReason.PARTICIPANT_WITHDREW) + else -> stopAndTransitionFromClosed( + ExperimentState.WITHDRAWN, + TransitionReason.PARTICIPANT_WITHDREW, + ) } } suspend fun ensureOccurrence(planned: InterventionOccurrence): InterventionOccurrence = metadataMutex.withLock { + check(pendingSafetyPause.get() == null) { + "Occurrence planning is disabled while a safety pause is pending" + } val metadata = requireMetadata() + check(metadata.state == ExperimentState.RUNNING) { + "Occurrences can be planned only while the study is running" + } + val now = clocks.now() + check(withinSignedDuration(now)) { + "Occurrences cannot be changed after the signed study duration" + } metadata.occurrences[planned.occurrenceId]?.let { existing -> if (existing.state == OccurrenceState.SCHEDULED && (existing.scheduledFor.wallTimeUtcMillis != planned.scheduledFor.wallTimeUtcMillis || @@ -248,7 +499,7 @@ class ExperimentRuntime( metadata.copy(occurrences = metadata.occurrences + (revised.occurrenceId to revised)), revised, "INTERVENTION_RESCHEDULED", - clocks.now(), + now, ) return@withLock revised } @@ -259,19 +510,22 @@ class ExperimentRuntime( metadata.copy(occurrences = metadata.occurrences + (planned.occurrenceId to planned)), planned, "INTERVENTION_SCHEDULED", - clocks.now(), + now, ) planned } suspend fun claimOccurrenceIfDue(occurrenceId: String): OccurrenceClaimResult = metadataMutex.withLock { val metadata = requireMetadata() - if (metadata.state != ExperimentState.RUNNING) return@withLock OccurrenceClaimResult.InactiveStudy + if (metadata.state != ExperimentState.RUNNING || pendingSafetyPause.get() != null) { + return@withLock OccurrenceClaimResult.InactiveStudy + } + val now = clocks.now() + if (!withinSignedDuration(now)) return@withLock OccurrenceClaimResult.InactiveStudy val occurrence = metadata.occurrences[occurrenceId] ?: return@withLock OccurrenceClaimResult.Missing if (occurrence.state !in setOf(OccurrenceState.SCHEDULED, OccurrenceState.POSTING)) { return@withLock OccurrenceClaimResult.Terminal } - val now = clocks.now() if (now.wallTimeUtcMillis >= occurrence.expiresAtUtcMillis) { expireOccurrence(metadata, occurrence, now) return@withLock OccurrenceClaimResult.Expired @@ -281,7 +535,7 @@ class ExperimentRuntime( val claimed = if (occurrence.state == OccurrenceState.SCHEDULED) { occurrence.copy(state = OccurrenceState.POSTING).also { next -> val updated = metadata.copy(occurrences = metadata.occurrences + (occurrenceId to next)) - store.saveMetadata(updated) + performStoreMutation { store.saveMetadata(updated) } currentMetadata = updated publishMetadata(updated) } @@ -301,7 +555,11 @@ class ExperimentRuntime( */ suspend fun expireOccurrenceIfDue(occurrenceId: String): OccurrenceExpiryResult = metadataMutex.withLock { val metadata = requireMetadata() - if (metadata.state != ExperimentState.RUNNING) return@withLock OccurrenceExpiryResult.InactiveStudy + if (metadata.state != ExperimentState.RUNNING || pendingSafetyPause.get() != null) { + return@withLock OccurrenceExpiryResult.InactiveStudy + } + val now = clocks.now() + if (!withinSignedDuration(now)) return@withLock OccurrenceExpiryResult.InactiveStudy val occurrence = metadata.occurrences[occurrenceId] ?: return@withLock OccurrenceExpiryResult.Missing if ( occurrence.state in setOf(OccurrenceState.EXPIRED, OccurrenceState.SURVEY_SUBMITTED) || @@ -309,7 +567,6 @@ class ExperimentRuntime( ) { return@withLock OccurrenceExpiryResult.Terminal } - val now = clocks.now() val remaining = occurrence.expiresAtUtcMillis - now.wallTimeUtcMillis if (remaining > 0) return@withLock OccurrenceExpiryResult.NotDue(remaining) expireOccurrence(metadata, occurrence, now) @@ -318,12 +575,13 @@ class ExperimentRuntime( suspend fun markNotificationPosted(occurrenceId: String): Boolean = metadataMutex.withLock { val metadata = requireMetadata() - if (metadata.state != ExperimentState.RUNNING) return@withLock false + if (metadata.state != ExperimentState.RUNNING || pendingSafetyPause.get() != null) return@withLock false + val now = clocks.now() + if (!withinSignedDuration(now)) return@withLock false val occurrence = metadata.occurrences[occurrenceId] ?: return@withLock false if (occurrence.state !in setOf(OccurrenceState.POSTING, OccurrenceState.NOTIFICATION_POSTED)) { return@withLock false } - val now = clocks.now() if (now.wallTimeUtcMillis >= occurrence.expiresAtUtcMillis) { expireOccurrence(metadata, occurrence, now) return@withLock false @@ -341,9 +599,10 @@ class ExperimentRuntime( suspend fun openOccurrence(occurrenceId: String): OccurrenceDispatch? = metadataMutex.withLock { val metadata = requireMetadata() - if (metadata.state != ExperimentState.RUNNING) return@withLock null - val occurrence = metadata.occurrences[occurrenceId] ?: return@withLock null + if (metadata.state != ExperimentState.RUNNING || pendingSafetyPause.get() != null) return@withLock null val now = clocks.now() + if (!withinSignedDuration(now)) return@withLock null + val occurrence = metadata.occurrences[occurrenceId] ?: return@withLock null if (now.wallTimeUtcMillis >= occurrence.expiresAtUtcMillis && occurrence.state != OccurrenceState.SURVEY_SUBMITTED) { expireOccurrence(metadata, occurrence, now) return@withLock null @@ -369,10 +628,13 @@ class ExperimentRuntime( answers: Map, ): SurveySubmissionResult = metadataMutex.withLock { val metadata = requireMetadata() - if (metadata.state != ExperimentState.RUNNING) return@withLock SurveySubmissionResult.INVALID + if (metadata.state != ExperimentState.RUNNING || pendingSafetyPause.get() != null) { + return@withLock SurveySubmissionResult.INVALID + } + val now = clocks.now() + if (!withinSignedDuration(now)) return@withLock SurveySubmissionResult.INVALID val occurrence = metadata.occurrences[occurrenceId] ?: return@withLock SurveySubmissionResult.INVALID if (occurrence.state == OccurrenceState.SURVEY_SUBMITTED) return@withLock SurveySubmissionResult.ALREADY_SUBMITTED - val now = clocks.now() if (now.wallTimeUtcMillis >= occurrence.expiresAtUtcMillis) { expireOccurrence(metadata, occurrence, now) return@withLock SurveySubmissionResult.EXPIRED @@ -434,7 +696,7 @@ class ExperimentRuntime( } if (sequenceInclusive <= metadata.uploadedThroughSequence) return@withLock metadata val updated = metadata.copy(uploadedThroughSequence = sequenceInclusive) - store.saveMetadata(updated) + performStoreMutation { store.saveMetadata(updated) } currentMetadata = updated publishMetadata(updated) updated @@ -455,10 +717,12 @@ class ExperimentRuntime( val metadata = requireMetadata() val usage = store.storageUsage() if (usage.fraction <= EVICT_ABOVE_FRACTION) return@withLock metadata - val updated = store.evictThrough( - metadata, - targetBytes = (usage.quotaBytes * EVICT_DOWN_TO_FRACTION).toLong(), - ) + val updated = performStoreMutation { + store.evictThrough( + metadata, + targetBytes = (usage.quotaBytes * EVICT_DOWN_TO_FRACTION).toLong(), + ) + } if (updated === metadata) return@withLock metadata currentMetadata = updated publishMetadata(updated) @@ -473,53 +737,118 @@ class ExperimentRuntime( healthJobs.clear() } - override fun captureToken(): AdmissionToken? = admissionGate.capture() + private fun captureStudyToken(): AdmissionToken? = admissionGate.capture() - override suspend fun emit( - token: AdmissionToken, + private suspend fun persistAdmittedEvent( + studyToken: AdmissionToken, + collectorGate: EventAdmissionGate, + collectorToken: AdmissionToken, event: EventDraft, ): EmitResult { - val descriptor = collectorEntries[event.collectorId]?.plugin?.descriptor - ?: return EmitResult.ContractViolation - if (!descriptor.eventContract.accepts(event, Long.MAX_VALUE)) return EmitResult.ContractViolation - if (!admissionGate.accepts(token, event.observedTime.elapsedRealtimeNanos)) { + if (!registerAdmittedWrite( + studyToken, + collectorGate, + collectorToken, + event.observedTime, + ) + ) { return EmitResult.RejectedByAdmissionGate } - return metadataMutex.withLock { - if (!admissionGate.accepts(token, event.observedTime.elapsedRealtimeNanos)) { - return@withLock EmitResult.RejectedByAdmissionGate - } - val metadata = requireMetadata() - val recorded = RecordedEvent( - sequenceNumber = metadata.nextSequenceNumber, - collectorId = event.collectorId, - payloadSchemaVersion = event.payloadSchemaVersion, - observedTime = event.observedTime, - payloadType = event.payloadType, - fields = event.fields.toSortedMap(), - ) - val updated = metadata.copy( - eventCount = metadata.eventCount + 1, - nextSequenceNumber = metadata.nextSequenceNumber + 1, - lastEvents = metadata.lastEvents + (recorded.collectorId to recorded), - ) - try { - store.appendEventAtomically(recorded, updated) - currentMetadata = updated - publishMetadata(updated) - EmitResult.Accepted(recorded.sequenceNumber) - } catch (failure: Throwable) { - failure.rethrowIfCancellation() - admissionGate.forceClose() - mutableSnapshot.update { it.copy(incidentCode = INCIDENT_STORAGE_WRITE_FAILED) } - scope.launch { failClosedAfterStorageFailure() } - EmitResult.StorageFailure + return try { + metadataMutex.withLock { + if (!acceptsAdmission( + studyToken, + collectorGate, + collectorToken, + event.observedTime, + ) + ) { + return@withLock EmitResult.RejectedByAdmissionGate + } + val metadata = requireMetadata() + val recorded = RecordedEvent( + sequenceNumber = metadata.nextSequenceNumber, + collectorId = event.collectorId, + payloadSchemaVersion = event.payloadSchemaVersion, + observedTime = event.observedTime, + payloadType = event.payloadType, + fields = event.fields.toSortedMap(), + ) + val updated = metadata.copy( + eventCount = metadata.eventCount + 1, + nextSequenceNumber = metadata.nextSequenceNumber + 1, + lastEvents = metadata.lastEvents + (recorded.collectorId to recorded), + ) + try { + appendEventAtomicallyOrSignalStorageFailure(recorded, updated) + currentMetadata = updated + publishMetadata(updated) + EmitResult.Accepted(recorded.sequenceNumber) + } catch (failure: Throwable) { + failure.rethrowIfCancellation() + EmitResult.StorageFailure + } } + } finally { + withContext(NonCancellable) { completeAdmittedWrite() } + } + } + + private suspend fun registerAdmittedWrite( + studyToken: AdmissionToken, + collectorGate: EventAdmissionGate, + collectorToken: AdmissionToken, + observedAt: ResearchTime, + ): Boolean = admittedWriteMutex.withLock { + if (!acceptsAdmission(studyToken, collectorGate, collectorToken, observedAt)) { + return@withLock false } + if (admittedWriteCount == 0) admittedWritesDrained = CompletableDeferred() + admittedWriteCount += 1 + true } - override suspend fun latestEvent(collectorId: String): RecordedEvent? = metadataMutex.withLock { + private suspend fun completeAdmittedWrite() = admittedWriteMutex.withLock { + check(admittedWriteCount > 0) { "Admitted write accounting underflow" } + admittedWriteCount -= 1 + if (admittedWriteCount == 0) admittedWritesDrained.complete(Unit) + } + + private suspend fun awaitAdmittedWritesDrained() { + admittedWriteMutex.withLock { admittedWritesDrained }.await() + } + + private fun acceptsAdmission( + studyToken: AdmissionToken, + collectorGate: EventAdmissionGate, + collectorToken: AdmissionToken, + observedAt: ResearchTime, + ): Boolean = admissionGate.accepts(studyToken, observedAt.elapsedRealtimeNanos) && + collectorGate.accepts(collectorToken, observedAt.elapsedRealtimeNanos) && + withinSignedDuration(observedAt) + + /** + * Uses the participant's one signed start transition as the immutable admission boundary. + * + * Wall time is deliberately irrelevant: it can jump while Android is running. A timestamp + * from another boot, before the start, or at/after the exact monotonic deadline is not + * admissible. Returning false rather than estimating across a reboot preserves fail-closed + * recovery semantics. + */ + private fun withinSignedDuration(observedAt: ResearchTime): Boolean { + val start = currentMetadata?.transitions?.singleOrNull { + it.reason == TransitionReason.PARTICIPANT_STARTED + } ?: return false + if (start.from != ExperimentState.READY || start.to != ExperimentState.RUNNING) return false + if (start.time.bootSessionId != observedAt.bootSessionId) return false + if (observedAt.elapsedRealtimeNanos < start.time.elapsedRealtimeNanos) return false + val elapsedNanos = observedAt.elapsedRealtimeNanos - start.time.elapsedRealtimeNanos + val durationNanos = configuration.durationHours.toLong() * NANOS_PER_HOUR + return elapsedNanos < durationNanos + } + + private suspend fun latestEvent(collectorId: String): RecordedEvent? = metadataMutex.withLock { requireMetadata().lastEvents[collectorId] } @@ -532,6 +861,8 @@ class ExperimentRuntime( if (requireInitialized) check(currentMetadata != null) { "Runtime is not initialized" } command() CommandResult.Success + } catch (failure: CancellationException) { + throw failure } catch (_: IllegalArgumentException) { mutableSnapshot.update { it.copy(incidentCode = INCIDENT_COMMAND_REJECTED) } CommandResult.Failed(INCIDENT_COMMAND_REJECTED) @@ -549,22 +880,28 @@ class ExperimentRuntime( private fun createCollectors() { check(collectorEntries.isEmpty()) { "Collectors already exist" } configuredPlugins().forEach { (configuration, plugin) -> + val collectorAdmissionGate = EventAdmissionGate() val collector = plugin.create( configuration, CollectorContext( scope = scope, - eventSink = CollectorEventSink(plugin.descriptor), + eventSink = CollectorEventSink(plugin.descriptor, collectorAdmissionGate), clocks = clocks, ), ) - collectorEntries[plugin.descriptor.id] = CollectorEntry( + val entry = CollectorEntry( collector = collector, configuration = configuration, plugin = plugin, + admissionGate = collectorAdmissionGate, ) + collectorEntries[plugin.descriptor.id] = entry healthJobs += scope.launch { collector.health.collect { health -> - updateCollectorHealth(plugin.descriptor.id, health) + updateCollectorHealth( + plugin.descriptor.id, + entry.presentedHealth(health), + ) } } } @@ -574,56 +911,106 @@ class ExperimentRuntime( collectorConfiguration to collectorRegistry.pluginFor(collectorConfiguration) } - private suspend fun activateCollectors() { + private suspend fun activateCollectors(availableKinds: Set) { collectorEntries.forEach { (id, entry) -> - val missingAccess = entry.plugin.accessRequirements(entry.configuration) - .filter { it.kind !in availableAccess() } + val missingAccess = entry.plugin.descriptor.accessRequirements(entry.configuration.required) + .filter { it.kind !in availableKinds } if (missingAccess.isNotEmpty()) { - updateCollectorHealth( - id, - CollectorHealth(CollectorStatus.BLOCKED_ACCESS, "ACCESS_UNAVAILABLE"), - ) + entry.closeAdmission() + entry.accessBlocked = true + publishCollectorHealth(id, entry) return@forEach } - try { - if (entry.hasStarted) { - entry.collector.resume() - } else { - entry.collector.start() - entry.hasStarted = true - } - } catch (failure: Throwable) { - entry.hasStarted = entry.hasStarted || entry.collector.requiresStop - failure.rethrowIfCancellation() - updateCollectorHealth(id, CollectorHealth(CollectorStatus.FAILED, "COLLECTOR_START_FAILED")) + entry.accessBlocked = false + activateCollector(id, entry, resume = entry.hasStarted) + } + } + + /** Opens collector admission only after its source reports a successful start or resume. */ + private suspend fun activateCollector( + id: String, + entry: CollectorEntry, + resume: Boolean, + ) { + entry.closeAdmission() + try { + if (resume) { + entry.collector.resume() + } else { + entry.collector.start() + entry.hasStarted = true } + entry.sourcePaused = false + entry.admissionFailureReason = null + entry.openAdmission() + entry.collector.onAdmissionOpened() + publishCollectorHealth(id, entry) + } catch (failure: Throwable) { + entry.closeAdmission() + entry.hasStarted = entry.hasStarted || entry.collector.requiresStop + failure.rethrowIfCancellation() + entry.admissionFailureReason = if (resume) { + "COLLECTOR_RESUME_FAILED" + } else { + "COLLECTOR_START_FAILED" + } + publishCollectorHealth(id, entry) } } - private suspend fun pauseCollectors() { + private suspend fun pauseCollectors(): Boolean { + var allPaused = true collectorEntries.forEach { (id, entry) -> - if (!entry.hasStarted) return@forEach + if (!entry.hasStarted) { + entry.closeAdmission() + return@forEach + } try { entry.collector.pause() + entry.sourcePaused = true + entry.admissionFailureReason = null } catch (failure: Throwable) { failure.rethrowIfCancellation() - updateCollectorHealth(id, CollectorHealth(CollectorStatus.FAILED, "COLLECTOR_PAUSE_FAILED")) + allPaused = false + entry.admissionFailureReason = "COLLECTOR_PAUSE_FAILED" + publishCollectorHealth(id, entry) + } finally { + // The global boundary is already draining or closed. Retire this collector epoch + // after teardown as a second, collector-owned admission boundary. + entry.closeAdmission() } } + return allPaused + } + + private fun closeAllAdmission() { + admissionGate.forceClose() + collectorEntries.values.forEach(CollectorEntry::closeAdmission) } - private suspend fun stopCollectors() { + private suspend fun stopCollectors(): Boolean { + var allStopped = true collectorEntries.forEach { (id, entry) -> - if (!entry.hasStarted) return@forEach + if (!entry.hasStarted) { + entry.closeAdmission() + return@forEach + } + entry.accessBlocked = false try { entry.collector.stop() + entry.sourcePaused = false + entry.admissionFailureReason = null } catch (failure: Throwable) { failure.rethrowIfCancellation() - updateCollectorHealth(id, CollectorHealth(CollectorStatus.FAILED, "COLLECTOR_STOP_FAILED")) + allStopped = false + entry.admissionFailureReason = "COLLECTOR_STOP_FAILED" + publishCollectorHealth(id, entry) } finally { + entry.closeAdmission() entry.hasStarted = entry.collector.requiresStop } } + return allStopped } private suspend fun drainAndTransition( @@ -634,15 +1021,75 @@ class ExperimentRuntime( val boundary = clocks.now() val token = admissionGate.beginDrain(boundary.elapsedRealtimeNanos) try { + // Metadata is committed only after every write already admitted at the boundary has + // finished. Closing after source teardown rejects late callbacks; a storage failure + // either becomes the stronger pause reason or prevents a terminal export entirely. + val sourcesReleased = if (stopCollectors) stopCollectors() else pauseCollectors() + admissionGate.close(token) + awaitAdmittedWritesDrained() + if (!sourcesReleased) { + signalSafetyFailure( + SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE, + INCIDENT_COLLECTION_TEARDOWN_FAILED, + ) + } + if (stopCollectors) { + check(pendingSafetyPause.get() == null) { + "A safety failure prevents the terminal transition" + } + } transitionTo(to, reason, boundary) + check(sourcesReleased) { + "Collector teardown failed at the pause boundary" + } } catch (failure: Throwable) { - admissionGate.restoreActive(token) + admissionGate.forceClose() + persistCancelledTeardownWitness(failure) throw failure } + } + + private suspend fun stopAndTransitionFromClosed( + to: ExperimentState, + reason: TransitionReason, + ) { + val startedPaused = currentMetadata?.state == ExperimentState.PAUSED try { - if (stopCollectors) stopCollectors() else pauseCollectors() - } finally { - admissionGate.close(token) + if (!stopCollectors()) { + signalSafetyFailure( + SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE, + INCIDENT_COLLECTION_TEARDOWN_FAILED, + ) + error("Collector teardown failed before the terminal transition") + } + awaitAdmittedWritesDrained() + check(pendingSafetyPause.get() == null) { + "A safety failure prevents the terminal transition" + } + transitionTo(to, reason) + } catch (failure: Throwable) { + closeAllAdmission() + if (startedPaused) persistCancelledTeardownWitness(failure) + throw failure + } + } + + /** + * Once a drain has started, caller cancellation cannot be allowed to erase the only evidence + * that source teardown is incomplete. Persist the typed witness before propagating cancellation; + * recovery will enter PAUSED before any host or collector can reopen. + */ + private suspend fun persistCancelledTeardownWitness(failure: Throwable) { + if (failure !is CancellationException) return + try { + withContext(NonCancellable) { + signalSafetyFailure( + SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE, + INCIDENT_COLLECTION_TEARDOWN_FAILED, + ) + } + } catch (witnessFailure: Throwable) { + failure.addSuppressed(witnessFailure) } } @@ -652,27 +1099,23 @@ class ExperimentRuntime( time: cool.jacoblin.particeps.core.model.ResearchTime = clocks.now(), ) { metadataMutex.withLock { - val updated = stateMachine.transition(requireMetadata(), state, reason, time) - store.saveMetadata(updated) + // A storage failure can be published while a participant pause is waiting on this + // mutex. Linearize that concurrent pause as the stronger safety transition so its + // durable reason can never become PARTICIPANT_PAUSED after the fail-closed signal. + val effectiveReason = if (state == ExperimentState.PAUSED) { + pendingSafetyPause.get()?.transitionReason ?: reason + } else { + reason + } + val updated = stateMachine.transition(requireMetadata(), state, effectiveReason, time) + performStoreMutation(mayHaveCommittedRunning = state == ExperimentState.RUNNING) { + store.saveMetadata(updated) + } currentMetadata = updated publishMetadata(updated) } } - private suspend fun failClosedAfterStorageFailure() { - commandMutex.withLock { - pauseCollectors() - val metadata = currentMetadata ?: return@withLock - if (metadata.state != ExperimentState.RUNNING) return@withLock - try { - transitionTo(ExperimentState.PAUSED, TransitionReason.STORAGE_FAILURE) - } catch (failure: Throwable) { - failure.rethrowIfCancellation() - mutableSnapshot.update { it.copy(incidentCode = INCIDENT_PAUSE_PERSISTENCE_FAILED) } - } - } - } - private fun requireMetadata(): StudyMetadata = checkNotNull(currentMetadata) { "Runtime is not initialized" } private fun intervention(occurrence: InterventionOccurrence) = @@ -703,9 +1146,12 @@ class ExperimentRuntime( metadataAfterState: StudyMetadata, occurrence: InterventionOccurrence, payloadType: String, - observedAt: cool.jacoblin.particeps.core.model.ResearchTime, + observedAt: ResearchTime, additionalFields: Map = emptyMap(), ) { + check(withinSignedDuration(observedAt)) { + "Occurrence events cannot be admitted after the signed study duration" + } val draft = EventDraft( collectorId = INTERVENTION_COLLECTOR_ID, payloadSchemaVersion = 1, @@ -735,11 +1181,129 @@ class ExperimentRuntime( nextSequenceNumber = event.sequenceNumber + 1, lastEvents = metadataAfterState.lastEvents + (event.collectorId to event), ) - store.appendEventAtomically(event, updated) + try { + appendEventAtomicallyOrSignalStorageFailure(event, updated) + } catch (failure: StudyStoreMutationFailedClosed) { + val recovered = requireNotNull(currentMetadata) + val recoveredEvent = recovered.lastEvents[INTERVENTION_COLLECTOR_ID] + if ( + recovered.eventCount != updated.eventCount || + recoveredEvent != event || + recovered.occurrences[occurrence.occurrenceId] != occurrence + ) { + throw failure + } + // The occurrence mutation and its event are durable even though the whole study has + // entered a typed storage safety pause. Reporting semantic success keeps an already + // posted Android notification consistent with durable NOTIFICATION_POSTED state. + return + } currentMetadata = updated publishMetadata(updated) } + /** + * Makes every atomic event append share the same fail-closed storage boundary. + * + * Callers may translate or propagate the original failure, but the typed request is published + * first while their metadata critical section is still held. This method never takes + * [commandMutex], preserving the runtime's command -> metadata lock ordering. + */ + private suspend fun appendEventAtomicallyOrSignalStorageFailure( + event: RecordedEvent, + metadata: StudyMetadata, + ) { + try { + store.appendEventAtomically(event, metadata, clocks.now()) + } catch (failure: Throwable) { + val recovered = (failure as? StudyStoreMutationFailedClosed)?.metadata + if (recovered != null) { + currentMetadata = recovered + publishMetadata(recovered) + } + var effectiveReason = pendingSafetyPause.get() ?: SafetyPauseReason.STORAGE_FAILURE + try { + effectiveReason = signalStorageFailure() + } catch (witnessFailure: Throwable) { + failure.addSuppressed(witnessFailure) + effectiveReason = pendingSafetyPause.get() ?: effectiveReason + } + if (recovered != null) { + try { + store.resolvePendingAppendFailure(effectiveReason.transitionReason)?.let { resolved -> + currentMetadata = resolved + publishMetadata(resolved) + } + } catch (resolutionFailure: Throwable) { + failure.addSuppressed(resolutionFailure) + } + } + val cancellation = failure.cause as? CancellationException + throw cancellation ?: failure + } + } + + /** Protects every mutable store operation that can strand a durably RUNNING study. */ + private suspend fun performStoreMutation( + mayHaveCommittedRunning: Boolean = false, + mutation: suspend () -> T, + ): T = try { + mutation() + } catch (failure: Throwable) { + if (currentMetadata?.state == ExperimentState.RUNNING || mayHaveCommittedRunning) { + try { + signalStorageFailure() + } catch (witnessFailure: Throwable) { + failure.addSuppressed(witnessFailure) + } + } + throw failure + } + + /** + * Closes live collection before publishing a storage request without acquiring commandMutex. + * The atomic latch makes concurrent access/host/storage failures deterministically first-wins. + */ + private suspend fun signalStorageFailure(): SafetyPauseReason = + signalSafetyFailure( + SafetyPauseReason.STORAGE_FAILURE, + INCIDENT_STORAGE_WRITE_FAILED, + ) + + private suspend fun signalSafetyFailure( + reason: SafetyPauseReason, + incidentCode: String, + ): SafetyPauseReason { + closeAllAdmission() + val effectiveReason = latchSafetyPauseReason(reason) + mutableSnapshot.update { it.copy(incidentCode = incidentCode) } + withContext(NonCancellable) { + safetyPauseWitness.persist(effectiveReason) + } + return effectiveReason + } + + private fun latchSafetyPauseReason(requested: SafetyPauseReason): SafetyPauseReason { + while (true) { + val existing = pendingSafetyPause.get() + if (existing != null) { + publishPendingSafetyPause(existing) + return existing + } + if (pendingSafetyPause.compareAndSet(null, requested)) { + publishPendingSafetyPause(requested) + return requested + } + } + } + + private fun publishPendingSafetyPause(reason: SafetyPauseReason) { + mutableSnapshot.update { current -> + if (current.pendingSafetyPauseReason == reason) current + else current.copy(pendingSafetyPauseReason = reason) + } + } + private fun validateAndEncodeAnswers(survey: SurveyDefinition, answers: Map): String? { if (answers.keys.any { key -> survey.questions.none { it.id == key } }) return null survey.questions.forEach { question -> @@ -805,25 +1369,84 @@ class ExperimentRuntime( } } - private data class CollectorEntry( + private fun publishCollectorHealth( + collectorId: String, + entry: CollectorEntry, + ) { + updateCollectorHealth(collectorId, entry.presentedHealth(entry.collector.health.value)) + } + + private class CollectorEntry( val collector: Collector, val configuration: cool.jacoblin.particeps.core.definition.CollectorConfiguration, val plugin: CollectorPlugin, - var hasStarted: Boolean = false, - ) + val admissionGate: EventAdmissionGate, + ) { + var hasStarted: Boolean = false + var sourcePaused: Boolean = false + var admissionOpen: Boolean = false + + @Volatile + var accessBlocked: Boolean = false + + @Volatile + var admissionFailureReason: String? = null + + fun openAdmission() { + admissionGate.open() + admissionOpen = true + } + + fun closeAdmission() { + admissionGate.forceClose() + admissionOpen = false + } + + fun presentedHealth(sourceHealth: CollectorHealth): CollectorHealth { + val failureReason = admissionFailureReason + return when { + failureReason != null -> CollectorHealth( + CollectorStatus.FAILED, + failureReason, + ) + accessBlocked && sourceHealth.status != CollectorStatus.FAILED -> + CollectorHealth(CollectorStatus.BLOCKED_ACCESS, "ACCESS_UNAVAILABLE") + else -> sourceHealth + } + } + } + + private data class CollectorAdmissionToken( + val studyToken: AdmissionToken, + val collectorToken: AdmissionToken, + ) : AdmissionToken /** Binds a collector's shared admission capability to its own declared event contract. */ private inner class CollectorEventSink( private val descriptor: CollectorDescriptor, + private val collectorAdmissionGate: EventAdmissionGate, ) : EventSink { - override fun captureToken(): AdmissionToken? = this@ExperimentRuntime.captureToken() + override fun captureToken(): AdmissionToken? { + val collectorToken = collectorAdmissionGate.capture() ?: return null + val studyToken = captureStudyToken() ?: return null + return CollectorAdmissionToken(studyToken, collectorToken) + } - override suspend fun emit(token: AdmissionToken, event: EventDraft): EmitResult = - if (event.collectorId != descriptor.id) { - EmitResult.ContractViolation - } else { - this@ExperimentRuntime.emit(token, event) + override suspend fun emit(token: AdmissionToken, event: EventDraft): EmitResult { + if (event.collectorId != descriptor.id || + !descriptor.eventContract.accepts(event, Long.MAX_VALUE) + ) { + return EmitResult.ContractViolation } + val admission = token as? CollectorAdmissionToken + ?: return EmitResult.RejectedByAdmissionGate + return persistAdmittedEvent( + studyToken = admission.studyToken, + event = event, + collectorGate = collectorAdmissionGate, + collectorToken = admission.collectorToken, + ) + } override suspend fun latestEvent(collectorId: String): RecordedEvent? { require(collectorId == descriptor.id) { "Collector cannot inspect another collector's event" } @@ -848,9 +1471,12 @@ class ExperimentRuntime( const val EVICT_DOWN_TO_FRACTION = 0.60 const val INCIDENT_STORAGE_WRITE_FAILED = "STORAGE_WRITE_FAILED" + const val INCIDENT_COLLECTION_TEARDOWN_FAILED = "COLLECTION_TEARDOWN_FAILED" const val INCIDENT_PAUSE_PERSISTENCE_FAILED = "PAUSE_PERSISTENCE_FAILED" + const val INCIDENT_COLLECTOR_PAUSE_FAILED = "COLLECTOR_PAUSE_FAILED" const val MAXIMUM_SURVEY_ANSWERS_BYTES = 60 * 1024 const val INTERVENTION_COLLECTOR_ID = "interventions.v1" + const val NANOS_PER_HOUR = 60L * 60L * 1_000_000_000L } } diff --git a/core/experiment-runtime/src/test/kotlin/cool/jacoblin/particeps/core/runtime/EventAdmissionGateTest.kt b/core/experiment-runtime/src/test/kotlin/cool/jacoblin/particeps/core/runtime/EventAdmissionGateTest.kt index 5834cd0..50e3810 100644 --- a/core/experiment-runtime/src/test/kotlin/cool/jacoblin/particeps/core/runtime/EventAdmissionGateTest.kt +++ b/core/experiment-runtime/src/test/kotlin/cool/jacoblin/particeps/core/runtime/EventAdmissionGateTest.kt @@ -34,4 +34,17 @@ class EventAdmissionGateTest { assertFalse(gate.accepts(first, 1)) assertTrue(gate.accepts(second, 11)) } + + @Test + fun aTokenIsBoundToTheGateThatIssuedIt() { + val first = EventAdmissionGate() + val second = EventAdmissionGate() + val firstToken = first.open() + val secondToken = second.open() + + assertTrue(first.accepts(firstToken, 1)) + assertTrue(second.accepts(secondToken, 1)) + assertFalse(first.accepts(secondToken, 1)) + assertFalse(second.accepts(firstToken, 1)) + } } diff --git a/core/experiment-runtime/src/test/kotlin/cool/jacoblin/particeps/core/runtime/ExperimentRuntimeTest.kt b/core/experiment-runtime/src/test/kotlin/cool/jacoblin/particeps/core/runtime/ExperimentRuntimeTest.kt index 3e4f47f..bd5e47b 100644 --- a/core/experiment-runtime/src/test/kotlin/cool/jacoblin/particeps/core/runtime/ExperimentRuntimeTest.kt +++ b/core/experiment-runtime/src/test/kotlin/cool/jacoblin/particeps/core/runtime/ExperimentRuntimeTest.kt @@ -2,15 +2,20 @@ package cool.jacoblin.particeps.core.runtime import cool.jacoblin.particeps.core.model.EventDraft import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.ExperimentStateMachine +import cool.jacoblin.particeps.core.model.ExperimentTransition import cool.jacoblin.particeps.core.model.InterventionOccurrence import cool.jacoblin.particeps.core.model.OccurrenceState import cool.jacoblin.particeps.core.model.RecordedEvent import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.SafetyPauseReason import cool.jacoblin.particeps.core.model.StorageUsage import cool.jacoblin.particeps.core.model.StudyMetadata import cool.jacoblin.particeps.core.model.StudyStore +import cool.jacoblin.particeps.core.model.StudyStoreMutationFailedClosed +import cool.jacoblin.particeps.core.model.TransitionReason import cool.jacoblin.particeps.core.collector.AccessKind -import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.collector.AdmissionToken import cool.jacoblin.particeps.core.collector.Collector import cool.jacoblin.particeps.core.collector.CollectorContext import cool.jacoblin.particeps.core.collector.CollectorDescriptor @@ -23,7 +28,9 @@ import cool.jacoblin.particeps.core.collector.CollectorPlugin import cool.jacoblin.particeps.core.collector.CollectorRegistry import cool.jacoblin.particeps.core.collector.CollectorStatus import cool.jacoblin.particeps.core.collector.EmitResult +import cool.jacoblin.particeps.core.collector.EventSink import cool.jacoblin.particeps.core.definition.AppLifecycleConfiguration +import cool.jacoblin.particeps.core.definition.BatteryStateConfiguration import cool.jacoblin.particeps.core.definition.CollectorConfiguration import cool.jacoblin.particeps.core.definition.ExportConfiguration import cool.jacoblin.particeps.core.definition.ChoiceOption @@ -42,12 +49,17 @@ import cool.jacoblin.particeps.core.definition.SignerIdentity import cool.jacoblin.particeps.core.definition.StudyConfiguration import cool.jacoblin.particeps.core.definition.SurveyAction import cool.jacoblin.particeps.core.definition.SurveyDefinition +import java.io.IOException import java.time.Instant +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -62,7 +74,7 @@ class ExperimentRuntimeTest { val clocks = FakeClocks() val plugin = FakeCollectorPlugin( clocks, - AccessRequirement(AccessKind.GYROSCOPE_HARDWARE, required = true), + setOf(AccessKind.GYROSCOPE_HARDWARE), ) val runtime = ExperimentRuntime( configuration = configuration(), @@ -70,7 +82,7 @@ class ExperimentRuntimeTest { collectorRegistry = CollectorRegistry(listOf(plugin)), clocks = clocks, scope = backgroundScope, - availableAccess = { emptySet() }, + safetyPauseWitness = RecordingSafetyPauseWitness(), ) assertEquals(CommandResult.Success, runtime.initialize()) @@ -86,23 +98,24 @@ class ExperimentRuntimeTest { val clocks = FakeClocks() val plugin = FakeCollectorPlugin( clocks, - AccessRequirement(AccessKind.GYROSCOPE_HARDWARE, required = false), + setOf(AccessKind.GYROSCOPE_HARDWARE), ) - var available = emptySet() val runtime = ExperimentRuntime( - configuration = configuration(), + configuration = configuration( + collectors = listOf(AppLifecycleConfiguration(required = false)), + ), store = InMemoryStudyStore(), collectorRegistry = CollectorRegistry(listOf(plugin)), clocks = clocks, scope = backgroundScope, - availableAccess = { available }, + safetyPauseWitness = RecordingSafetyPauseWitness(), ) assertEquals(CommandResult.Success, runtime.initialize()) assertEquals(CommandResult.Success, runtime.reviewStudy()) assertEquals(CommandResult.Success, runtime.acceptConsent()) assertEquals(CommandResult.Success, runtime.completeAccessSetup(emptySet())) - assertEquals(CommandResult.Success, runtime.start()) + assertEquals(CommandResult.Success, runtime.start(emptySet())) assertEquals(0, plugin.collector.startCount) assertEquals( CollectorHealth(CollectorStatus.BLOCKED_ACCESS, "ACCESS_UNAVAILABLE"), @@ -110,12 +123,170 @@ class ExperimentRuntimeTest { ) assertEquals(CommandResult.Success, runtime.pause()) - available = setOf(AccessKind.GYROSCOPE_HARDWARE) - assertEquals(CommandResult.Success, runtime.resume()) + assertEquals( + CommandResult.Success, + runtime.resume(setOf(AccessKind.GYROSCOPE_HARDWARE)), + ) assertEquals(1, plugin.collector.startCount) assertEquals(CollectorStatus.ACTIVE, plugin.collector.health.value.status) } + @Test + fun optionalAccessLossClosesAdmissionBeforeAFailedPauseWhileTheSourceKeepsEmitting() = runTest { + val store = InMemoryStudyStore() + val clocks = FakeClocks() + val accessKind = AccessKind.GYROSCOPE_HARDWARE + val plugin = FakeCollectorPlugin(clocks, setOf(accessKind)) + val runtime = ExperimentRuntime( + configuration = configuration( + collectors = listOf(AppLifecycleConfiguration(required = false)), + ), + store = store, + collectorRegistry = CollectorRegistry(listOf(plugin)), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = RecordingSafetyPauseWitness(), + ) + start(runtime, setOf(accessKind)) + val emissionsDuringFailedPause = mutableListOf() + plugin.collector.beforePause = { + repeat(16) { + emissionsDuringFailedPause += plugin.emit("ACTIVITY_STOPPED") + } + } + plugin.collector.failNextPauseWithOwnedResources = true + + assertEquals(CommandResult.Success, runtime.reconcileCollectorAccess(emptySet())) + + assertEquals(1, plugin.collector.pauseCount) + assertTrue(plugin.collector.requiresStop) + assertEquals( + List(16) { EmitResult.RejectedByAdmissionGate }, + emissionsDuringFailedPause, + ) + repeat(16) { + assertEquals(EmitResult.RejectedByAdmissionGate, plugin.emit("ACTIVITY_STOPPED")) + } + assertEquals(0L, runtime.snapshot.value.metadata?.eventCount) + assertEquals( + CollectorHealth(CollectorStatus.FAILED, "COLLECTOR_PAUSE_FAILED"), + runtime.snapshot.value.collectorHealth[AppLifecycleConfiguration.ID], + ) + } + + @Test + fun optionalAccessLossTearsDownAFailedCollectorWhoseSourceIsStillLive() = runTest { + val store = InMemoryStudyStore() + val clocks = FakeClocks() + val accessKind = AccessKind.GYROSCOPE_HARDWARE + val plugin = FakeCollectorPlugin(clocks, setOf(accessKind)) + val runtime = ExperimentRuntime( + configuration = configuration( + collectors = listOf(AppLifecycleConfiguration(required = false)), + ), + store = store, + collectorRegistry = CollectorRegistry(listOf(plugin)), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = RecordingSafetyPauseWitness(), + ) + start(runtime, setOf(accessKind)) + plugin.collector.reportFailedWithOwnedResources() + assertTrue(plugin.collector.requiresStop) + assertTrue(plugin.emit("ACTIVITY_RESUMED") is EmitResult.Accepted) + + assertEquals(CommandResult.Success, runtime.reconcileCollectorAccess(emptySet())) + + assertEquals(1, plugin.collector.pauseCount) + assertEquals(EmitResult.RejectedByAdmissionGate, plugin.emit("ACTIVITY_STOPPED")) + assertEquals(1L, runtime.snapshot.value.metadata?.eventCount) + assertEquals( + CollectorHealth(CollectorStatus.BLOCKED_ACCESS, "ACCESS_UNAVAILABLE"), + runtime.snapshot.value.collectorHealth[AppLifecycleConfiguration.ID], + ) + } + + @Test + fun optionalAccessRestorationKeepsAdmissionClosedUntilResumeSucceeds() = runTest { + val clocks = FakeClocks() + val accessKind = AccessKind.GYROSCOPE_HARDWARE + val plugin = FakeCollectorPlugin(clocks, setOf(accessKind)) + val runtime = ExperimentRuntime( + configuration = configuration( + collectors = listOf(AppLifecycleConfiguration(required = false)), + ), + store = InMemoryStudyStore(), + collectorRegistry = CollectorRegistry(listOf(plugin)), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = RecordingSafetyPauseWitness(), + ) + start(runtime, setOf(accessKind)) + assertEquals(CommandResult.Success, runtime.reconcileCollectorAccess(emptySet())) + plugin.collector.failNextResumeWithOwnedResources = true + + assertEquals( + CommandResult.Success, + runtime.reconcileCollectorAccess(setOf(accessKind)), + ) + assertEquals(EmitResult.RejectedByAdmissionGate, plugin.emit("ACTIVITY_RESUMED")) + assertEquals( + CollectorHealth(CollectorStatus.FAILED, "COLLECTOR_RESUME_FAILED"), + runtime.snapshot.value.collectorHealth[AppLifecycleConfiguration.ID], + ) + + assertEquals( + CommandResult.Success, + runtime.reconcileCollectorAccess(setOf(accessKind)), + ) + assertEquals(2, plugin.collector.resumeCount) + assertTrue(plugin.emit("ACTIVITY_RESUMED") is EmitResult.Accepted) + } + + @Test + fun collectorAdmissionIsIndependentAndTokensCannotCrossBoundaries() = runTest { + val store = InMemoryStudyStore() + val clocks = FakeClocks() + val accessKind = AccessKind.GYROSCOPE_HARDWARE + val lifecyclePlugin = FakeCollectorPlugin(clocks, setOf(accessKind)) + val batteryPlugin = FakeCollectorPlugin( + clocks = clocks, + collectorId = BatteryStateConfiguration.ID, + ) + val runtime = ExperimentRuntime( + configuration = configuration( + collectors = listOf( + AppLifecycleConfiguration(required = false), + BatteryStateConfiguration(required = false), + ), + ), + store = store, + collectorRegistry = CollectorRegistry(listOf(lifecyclePlugin, batteryPlugin)), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = RecordingSafetyPauseWitness(), + ) + start(runtime, setOf(accessKind)) + val lifecycleToken = checkNotNull(lifecyclePlugin.captureToken()) + val forgedGlobalToken = object : AdmissionToken {} + assertEquals(CommandResult.Success, runtime.reconcileCollectorAccess(emptySet())) + + assertFalse(runtime as Any is EventSink) + assertEquals(1, lifecyclePlugin.collector.pauseCount) + assertEquals(0, batteryPlugin.collector.pauseCount) + assertEquals( + EmitResult.RejectedByAdmissionGate, + batteryPlugin.emitWithToken(lifecycleToken, "ACTIVITY_RESUMED"), + ) + assertEquals( + EmitResult.RejectedByAdmissionGate, + lifecyclePlugin.emitWithToken(forgedGlobalToken, "ACTIVITY_RESUMED"), + ) + assertEquals(EmitResult.RejectedByAdmissionGate, lifecyclePlugin.emit("ACTIVITY_RESUMED")) + assertTrue(batteryPlugin.emit("ACTIVITY_RESUMED") is EmitResult.Accepted) + assertEquals(1L, runtime.snapshot.value.metadata?.eventCount) + } + @Test fun participantCommandsGateAndPersistCollectorEvents() = runTest { val store = InMemoryStudyStore() @@ -127,7 +298,7 @@ class ExperimentRuntimeTest { collectorRegistry = CollectorRegistry(listOf(plugin)), clocks = clocks, scope = backgroundScope, - availableAccess = { emptySet() }, + safetyPauseWitness = RecordingSafetyPauseWitness(), ) assertEquals(CommandResult.Success, runtime.initialize()) @@ -138,7 +309,7 @@ class ExperimentRuntimeTest { assertEquals(CommandResult.Success, runtime.completeAccessSetup(emptySet())) assertEquals(ExperimentState.READY, runtime.snapshot.value.metadata?.state) - assertEquals(CommandResult.Success, runtime.start()) + assertEquals(CommandResult.Success, runtime.start(emptySet())) assertEquals(1, plugin.collector.startCount) assertEquals(CollectorStatus.ACTIVE, plugin.collector.health.value.status) assertTrue(plugin.emit("ACTIVITY_RESUMED") is EmitResult.Accepted) @@ -150,7 +321,7 @@ class ExperimentRuntimeTest { assertEquals(EmitResult.RejectedByAdmissionGate, plugin.emit("ACTIVITY_STOPPED")) assertEquals(1L, runtime.snapshot.value.metadata?.eventCount) - assertEquals(CommandResult.Success, runtime.resume()) + assertEquals(CommandResult.Success, runtime.resume(emptySet())) assertEquals(1, plugin.collector.resumeCount) assertTrue(plugin.emit("ACTIVITY_STARTED") is EmitResult.Accepted) assertEquals(listOf(1L, 2L), store.events.map { it.sequenceNumber }) @@ -164,10 +335,182 @@ class ExperimentRuntimeTest { assertNull(runtime.snapshot.value.incidentCode) } + @Test + fun collectorReceivesAdmissionOpenedOnlyAfterItsGateCanIssueTokens() = runTest { + val clocks = FakeClocks() + val plugin = FakeCollectorPlugin(clocks) + val runtime = ExperimentRuntime( + configuration = configuration(), + store = InMemoryStudyStore(), + collectorRegistry = CollectorRegistry(listOf(plugin)), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = RecordingSafetyPauseWitness(), + ) + assertEquals(CommandResult.Success, runtime.initialize()) + plugin.collector.afterAdmissionOpened = { + assertTrue(plugin.emit("ACTIVITY_STARTED") is EmitResult.Accepted) + } + + assertEquals(CommandResult.Success, runtime.reviewStudy()) + assertEquals(CommandResult.Success, runtime.acceptConsent()) + assertEquals(CommandResult.Success, runtime.completeAccessSetup(emptySet())) + assertEquals(CommandResult.Success, runtime.start(emptySet())) + + assertEquals(1, plugin.collector.admissionOpenedCount) + assertEquals(1L, runtime.snapshot.value.metadata?.eventCount) + + assertEquals(CommandResult.Success, runtime.pause()) + assertEquals(CommandResult.Success, runtime.resume(emptySet())) + assertEquals(2, plugin.collector.admissionOpenedCount) + assertEquals(2L, runtime.snapshot.value.metadata?.eventCount) + } + + @Test + fun admissionOpenedFailureClosesTheGateAndReportsCollectorActivationFailure() = runTest { + val clocks = FakeClocks() + val plugin = FakeCollectorPlugin(clocks) + val runtime = ExperimentRuntime( + configuration = configuration(), + store = InMemoryStudyStore(), + collectorRegistry = CollectorRegistry(listOf(plugin)), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = RecordingSafetyPauseWitness(), + ) + assertEquals(CommandResult.Success, runtime.initialize()) + plugin.collector.afterAdmissionOpened = { error("Initial snapshot failed") } + + assertEquals(CommandResult.Success, runtime.reviewStudy()) + assertEquals(CommandResult.Success, runtime.acceptConsent()) + assertEquals(CommandResult.Success, runtime.completeAccessSetup(emptySet())) + assertEquals(CommandResult.Success, runtime.start(emptySet())) + + assertEquals( + CollectorHealth(CollectorStatus.FAILED, "COLLECTOR_START_FAILED"), + runtime.snapshot.value.collectorHealth[AppLifecycleConfiguration.ID], + ) + assertNull(plugin.captureToken()) + assertEquals(0L, runtime.snapshot.value.metadata?.eventCount) + + assertEquals(CommandResult.Success, runtime.pause()) + plugin.collector.afterAdmissionOpened = { + assertTrue(plugin.emit("ACTIVITY_STARTED") is EmitResult.Accepted) + } + assertEquals(CommandResult.Success, runtime.resume(emptySet())) + assertEquals(CollectorStatus.ACTIVE, runtime.snapshot.value.collectorHealth[AppLifecycleConfiguration.ID]?.status) + assertEquals(1L, runtime.snapshot.value.metadata?.eventCount) + } + + @Test + fun collectorAdmissionStopsAtTheExactSignedDurationBoundary() = runTest { + val clocks = ControlledClocks(ResearchTime(10_000, 1_000, "boot-test")) + val store = InMemoryStudyStore() + val plugin = FakeCollectorPlugin(clocks) + val runtime = ExperimentRuntime( + configuration = configuration(), + store = store, + collectorRegistry = CollectorRegistry(listOf(plugin)), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = RecordingSafetyPauseWitness(), + ) + start(runtime) + val startedAt = runtime.snapshot.value.metadata?.transitions + ?.single { it.reason == TransitionReason.PARTICIPANT_STARTED } + ?.time ?: error("Missing participant-start boundary") + val deadlineElapsedNanos = startedAt.elapsedRealtimeNanos + NANOS_PER_HOUR + + clocks.current = ResearchTime( + wallTimeUtcMillis = startedAt.wallTimeUtcMillis + 3_599_999, + elapsedRealtimeNanos = deadlineElapsedNanos - 1, + bootSessionId = startedAt.bootSessionId, + ) + assertTrue(plugin.emit("ACTIVITY_RESUMED") is EmitResult.Accepted) + + clocks.current = clocks.current.copy( + wallTimeUtcMillis = startedAt.wallTimeUtcMillis + 3_600_000, + elapsedRealtimeNanos = deadlineElapsedNanos, + ) + assertEquals(EmitResult.RejectedByAdmissionGate, plugin.emit("ACTIVITY_STOPPED")) + assertEquals(1L, runtime.snapshot.value.metadata?.eventCount) + assertEquals(listOf(1L), store.events.map { it.sequenceNumber }) + assertEquals(ExperimentState.RUNNING, runtime.snapshot.value.metadata?.state) + } + + @Test + fun occurrenceMutationsCannotCrossTheExactSignedDurationBoundary() = runTest { + val clocks = ControlledClocks(ResearchTime(10_000, 1_000, "boot-test")) + val runtime = ExperimentRuntime( + configuration = configuration( + surveys = listOf(survey()), + interventions = listOf(surveyIntervention()), + ), + store = InMemoryStudyStore(), + collectorRegistry = CollectorRegistry(listOf(FakeCollectorPlugin(clocks))), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = RecordingSafetyPauseWitness(), + ) + start(runtime) + val startedAt = runtime.snapshot.value.metadata?.transitions + ?.single { it.reason == TransitionReason.PARTICIPANT_STARTED } + ?.time ?: error("Missing participant-start boundary") + val deadlineElapsedNanos = startedAt.elapsedRealtimeNanos + NANOS_PER_HOUR + clocks.current = ResearchTime( + wallTimeUtcMillis = startedAt.wallTimeUtcMillis + 3_599_999, + elapsedRealtimeNanos = deadlineElapsedNanos - 1, + bootSessionId = startedAt.bootSessionId, + ) + val occurrence = surveyOccurrence( + prefix = "e", + scheduledAtUtcMillis = 1, + expiresAtUtcMillis = Long.MAX_VALUE, + ) + runtime.ensureOccurrence(occurrence) + assertTrue(runtime.claimOccurrenceIfDue(occurrence.occurrenceId) is OccurrenceClaimResult.Due) + assertEquals(1L, runtime.snapshot.value.metadata?.eventCount) + + clocks.current = clocks.current.copy( + wallTimeUtcMillis = startedAt.wallTimeUtcMillis + 3_600_000, + elapsedRealtimeNanos = deadlineElapsedNanos, + ) + val second = surveyOccurrence( + prefix = "f", + scheduledAtUtcMillis = 1, + expiresAtUtcMillis = Long.MAX_VALUE, + ) + assertTrue(runCatching { runtime.ensureOccurrence(second) }.isFailure) + assertEquals(OccurrenceClaimResult.InactiveStudy, runtime.claimOccurrenceIfDue(occurrence.occurrenceId)) + assertEquals(OccurrenceExpiryResult.InactiveStudy, runtime.expireOccurrenceIfDue(occurrence.occurrenceId)) + assertFalse(runtime.markNotificationPosted(occurrence.occurrenceId)) + assertNull(runtime.openOccurrence(occurrence.occurrenceId)) + assertEquals( + SurveySubmissionResult.INVALID, + runtime.submitSurvey(occurrence.occurrenceId, validSurveyAnswers()), + ) + assertEquals(1L, runtime.snapshot.value.metadata?.eventCount) + assertEquals( + OccurrenceState.POSTING, + runtime.snapshot.value.metadata?.occurrences?.get(occurrence.occurrenceId)?.state, + ) + assertFalse(runtime.snapshot.value.metadata?.occurrences.orEmpty().containsKey(second.occurrenceId)) + } + @Test fun initializationRecoversRunningStateAndRestartsCollectors() = runTest { val store = InMemoryStudyStore( - StudyMetadata.initial(EXPERIMENT_ID, CONFIGURATION_ID).copy(state = ExperimentState.RUNNING), + StudyMetadata.initial(EXPERIMENT_ID, CONFIGURATION_ID).copy( + state = ExperimentState.RUNNING, + transitions = listOf( + ExperimentTransition( + from = ExperimentState.READY, + to = ExperimentState.RUNNING, + reason = TransitionReason.PARTICIPANT_STARTED, + time = ResearchTime(0, 0, "boot-test"), + ), + ), + ), ) val clocks = FakeClocks() val plugin = FakeCollectorPlugin(clocks) @@ -177,10 +520,11 @@ class ExperimentRuntimeTest { collectorRegistry = CollectorRegistry(listOf(plugin)), clocks = clocks, scope = backgroundScope, - availableAccess = { emptySet() }, + safetyPauseWitness = RecordingSafetyPauseWitness(), ) assertEquals(CommandResult.Success, runtime.initialize()) + assertEquals(CommandResult.Success, runtime.activateRecoveredRunning(emptySet())) assertEquals(ExperimentState.RUNNING, runtime.snapshot.value.metadata?.state) assertEquals(1, plugin.collector.startCount) @@ -197,7 +541,7 @@ class ExperimentRuntimeTest { collectorRegistry = CollectorRegistry(listOf(plugin)), clocks = clocks, scope = backgroundScope, - availableAccess = { emptySet() }, + safetyPauseWitness = RecordingSafetyPauseWitness(), ) assertEquals(CommandResult.Success, runtime.initialize()) plugin.collector.failNextStartWithOwnedResources = true @@ -205,7 +549,7 @@ class ExperimentRuntimeTest { assertEquals(CommandResult.Success, runtime.acceptConsent()) assertEquals(CommandResult.Success, runtime.completeAccessSetup(emptySet())) - assertEquals(CommandResult.Success, runtime.start()) + assertEquals(CommandResult.Success, runtime.start(emptySet())) assertTrue(plugin.collector.requiresStop) assertEquals(CollectorStatus.FAILED, runtime.snapshot.value.collectorHealth[AppLifecycleConfiguration.ID]?.status) @@ -224,14 +568,19 @@ class ExperimentRuntimeTest { collectorRegistry = CollectorRegistry(listOf(plugin)), clocks = clocks, scope = backgroundScope, - availableAccess = { emptySet() }, + safetyPauseWitness = RecordingSafetyPauseWitness(), ) start(runtime) plugin.collector.failNextStopWithOwnedResources = true - assertEquals(CommandResult.Success, runtime.finishEarly()) + assertEquals(CommandResult.Failed("COMMAND_REJECTED"), runtime.finishEarly()) assertEquals(1, plugin.collector.stopCount) assertTrue(plugin.collector.requiresStop) + assertEquals(ExperimentState.RUNNING, runtime.snapshot.value.metadata?.state) + assertEquals( + SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE, + runtime.snapshot.value.pendingSafetyPauseReason, + ) runtime.shutdown() assertEquals(2, plugin.collector.stopCount) @@ -249,7 +598,7 @@ class ExperimentRuntimeTest { collectorRegistry = CollectorRegistry(listOf(plugin)), clocks = clocks, scope = backgroundScope, - availableAccess = { emptySet() }, + safetyPauseWitness = RecordingSafetyPauseWitness(), ) start(runtime) @@ -263,6 +612,624 @@ class ExperimentRuntimeTest { assertEquals(0L, runtime.snapshot.value.metadata?.eventCount) } + @Test + fun storageAppendFailureClosesEveryAdmissionBoundaryAndPublishesTypedRequest() = runTest { + val store = InMemoryStudyStore() + val clocks = FakeClocks() + val lifecycle = FakeCollectorPlugin(clocks) + val battery = FakeCollectorPlugin(clocks, collectorId = BatteryStateConfiguration.ID) + val witness = RecordingSafetyPauseWitness() + val runtime = ExperimentRuntime( + configuration = configuration( + collectors = listOf( + AppLifecycleConfiguration(required = true), + BatteryStateConfiguration(required = true), + ), + ), + store = store, + collectorRegistry = CollectorRegistry(listOf(lifecycle, battery)), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = witness, + ) + start(runtime) + val staleBatteryToken = checkNotNull(battery.captureToken()) + store.appendFailure = IllegalStateException("storage unavailable") + + assertEquals(EmitResult.StorageFailure, lifecycle.emit("ACTIVITY_RESUMED")) + + assertEquals(SafetyPauseReason.STORAGE_FAILURE, runtime.snapshot.value.pendingSafetyPauseReason) + assertEquals(ExperimentState.RUNNING, runtime.snapshot.value.metadata?.state) + assertNull(lifecycle.captureToken()) + assertNull(battery.captureToken()) + assertEquals( + EmitResult.RejectedByAdmissionGate, + battery.emitWithToken(staleBatteryToken, "ACTIVITY_RESUMED"), + ) + assertEquals(0L, runtime.snapshot.value.metadata?.eventCount) + assertEquals(listOf(SafetyPauseReason.STORAGE_FAILURE), witness.persistedReasons) + } + + @Test + fun occurrenceAppendFailureUsesTheSameClosedTypedStorageBoundary() = runTest { + val store = InMemoryStudyStore() + val clocks = FakeClocks() + val collector = FakeCollectorPlugin(clocks) + val witness = RecordingSafetyPauseWitness() + val runtime = ExperimentRuntime( + configuration = configuration( + surveys = listOf(survey()), + interventions = listOf(surveyIntervention()), + ), + store = store, + collectorRegistry = CollectorRegistry(listOf(collector)), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = witness, + ) + start(runtime) + val occurrence = surveyOccurrence("d", scheduledAtUtcMillis = 1, expiresAtUtcMillis = 100_000) + runtime.ensureOccurrence(occurrence) + assertTrue(runtime.claimOccurrenceIfDue(occurrence.occurrenceId) is OccurrenceClaimResult.Due) + val staleToken = checkNotNull(collector.captureToken()) + val storageFailure = IllegalStateException("occurrence storage unavailable") + store.appendFailure = storageFailure + + assertEquals(storageFailure, runCatching { + runtime.markNotificationPosted(occurrence.occurrenceId) + }.exceptionOrNull()) + + assertEquals(SafetyPauseReason.STORAGE_FAILURE, runtime.snapshot.value.pendingSafetyPauseReason) + assertNull(collector.captureToken()) + assertEquals( + EmitResult.RejectedByAdmissionGate, + collector.emitWithToken(staleToken, "ACTIVITY_RESUMED"), + ) + assertEquals(OccurrenceState.POSTING, runtime.snapshot.value.metadata + ?.occurrences?.get(occurrence.occurrenceId)?.state) + assertEquals(OccurrenceClaimResult.InactiveStudy, runtime.claimOccurrenceIfDue(occurrence.occurrenceId)) + assertEquals(OccurrenceExpiryResult.InactiveStudy, runtime.expireOccurrenceIfDue(occurrence.occurrenceId)) + assertFalse(runtime.markNotificationPosted(occurrence.occurrenceId)) + assertNull(runtime.openOccurrence(occurrence.occurrenceId)) + assertEquals( + SurveySubmissionResult.INVALID, + runtime.submitSurvey(occurrence.occurrenceId, validSurveyAnswers()), + ) + assertTrue(runCatching { runtime.ensureOccurrence(occurrence) }.isFailure) + assertEquals(listOf(SafetyPauseReason.STORAGE_FAILURE), witness.persistedReasons) + } + + @Test + fun durablyRecoveredPostedOccurrenceReportsFinalizedWhileStudyFailsClosed() = runTest { + val store = InMemoryStudyStore() + val clocks = FakeClocks() + val witness = RecordingSafetyPauseWitness() + val runtime = ExperimentRuntime( + configuration = configuration( + surveys = listOf(survey()), + interventions = listOf(surveyIntervention()), + ), + store = store, + collectorRegistry = CollectorRegistry(listOf(FakeCollectorPlugin(clocks))), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = witness, + ) + start(runtime) + val occurrence = surveyOccurrence( + "e", + scheduledAtUtcMillis = 1, + expiresAtUtcMillis = 100_000, + ) + runtime.ensureOccurrence(occurrence) + assertTrue(runtime.claimOccurrenceIfDue(occurrence.occurrenceId) is OccurrenceClaimResult.Due) + store.recoverNextAppendFailClosed = true + + assertTrue(runtime.markNotificationPosted(occurrence.occurrenceId)) + + val snapshot = runtime.snapshot.value + assertEquals(ExperimentState.PAUSED, snapshot.metadata?.state) + assertEquals(SafetyPauseReason.STORAGE_FAILURE, snapshot.pendingSafetyPauseReason) + assertEquals( + OccurrenceState.NOTIFICATION_POSTED, + snapshot.metadata?.occurrences?.get(occurrence.occurrenceId)?.state, + ) + assertEquals("NOTIFICATION_POSTED", store.events.last().payloadType) + assertEquals(listOf(SafetyPauseReason.STORAGE_FAILURE), witness.persistedReasons) + } + + @Test + fun storageFailureWinsDeterministicallyWhenParticipantPauseWaitsForTheAppend() = runTest { + val store = InMemoryStudyStore() + val clocks = FakeClocks() + val collector = FakeCollectorPlugin(clocks) + val witness = RecordingSafetyPauseWitness() + val runtime = ExperimentRuntime( + configuration = configuration(), + store = store, + collectorRegistry = CollectorRegistry(listOf(collector)), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = witness, + ) + start(runtime) + store.appendEntered = CompletableDeferred() + store.releaseAppend = CompletableDeferred() + store.appendFailure = IllegalStateException("storage unavailable") + + val emission = async { collector.emit("ACTIVITY_RESUMED") } + store.appendEntered?.await() + val participantPause = async { runtime.pause() } + runCurrent() + assertFalse(participantPause.isCompleted) + + store.releaseAppend?.complete(Unit) + assertEquals(EmitResult.StorageFailure, emission.await()) + assertEquals(CommandResult.Success, participantPause.await()) + + assertEquals(ExperimentState.PAUSED, runtime.snapshot.value.metadata?.state) + assertEquals( + TransitionReason.STORAGE_FAILURE, + runtime.snapshot.value.metadata?.transitions?.last()?.reason, + ) + assertEquals(SafetyPauseReason.STORAGE_FAILURE, runtime.snapshot.value.pendingSafetyPauseReason) + assertEquals(CommandResult.Failed("COMMAND_REJECTED"), runtime.resume(emptySet())) + assertNull(collector.captureToken()) + assertEquals(listOf(SafetyPauseReason.STORAGE_FAILURE), witness.persistedReasons) + } + + @Test + fun firstPublishedSafetyReasonWinsAgainstAConcurrentStorageFailure() = runTest { + val store = InMemoryStudyStore() + val clocks = FakeClocks() + val collector = FakeCollectorPlugin(clocks) + val witness = RecordingSafetyPauseWitness() + val runtime = ExperimentRuntime( + configuration = configuration(), + store = store, + collectorRegistry = CollectorRegistry(listOf(collector)), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = witness, + ) + start(runtime) + store.appendEntered = CompletableDeferred() + store.releaseAppend = CompletableDeferred() + store.appendFailure = IllegalStateException("storage unavailable") + + val emission = async { collector.emit("ACTIVITY_RESUMED") } + store.appendEntered?.await() + assertEquals( + SafetyPauseReason.REQUIRED_ACCESS_MISSING, + runtime.closeAdmissionForSafetyFailure(SafetyPauseReason.REQUIRED_ACCESS_MISSING), + ) + val safetyPause = async { + runtime.pauseForSafetyFailure(SafetyPauseReason.REQUIRED_ACCESS_MISSING) + } + runCurrent() + assertFalse(safetyPause.isCompleted) + + store.releaseAppend?.complete(Unit) + assertEquals(EmitResult.StorageFailure, emission.await()) + assertEquals(CommandResult.Success, safetyPause.await()) + + assertEquals(SafetyPauseReason.REQUIRED_ACCESS_MISSING, runtime.snapshot.value.pendingSafetyPauseReason) + assertEquals( + TransitionReason.REQUIRED_ACCESS_MISSING, + runtime.snapshot.value.metadata?.transitions?.last()?.reason, + ) + assertEquals(listOf(SafetyPauseReason.REQUIRED_ACCESS_MISSING), witness.persistedReasons) + } + + @Test + fun firstPublishedReasonReplacesSyntheticRecoveredStorageTransition() = runTest { + val store = InMemoryStudyStore() + val clocks = FakeClocks() + val collector = FakeCollectorPlugin(clocks) + val witness = RecordingSafetyPauseWitness() + val runtime = ExperimentRuntime( + configuration = configuration(), + store = store, + collectorRegistry = CollectorRegistry(listOf(collector)), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = witness, + ) + start(runtime) + store.appendEntered = CompletableDeferred() + store.releaseAppend = CompletableDeferred() + store.recoverNextAppendFailClosed = true + + val emission = async { collector.emit("ACTIVITY_RESUMED") } + store.appendEntered?.await() + assertEquals( + SafetyPauseReason.REQUIRED_ACCESS_MISSING, + runtime.closeAdmissionForSafetyFailure(SafetyPauseReason.REQUIRED_ACCESS_MISSING), + ) + val safetyPause = async { + runtime.pauseForSafetyFailure(SafetyPauseReason.REQUIRED_ACCESS_MISSING) + } + runCurrent() + assertFalse(safetyPause.isCompleted) + + store.releaseAppend?.complete(Unit) + assertEquals(EmitResult.StorageFailure, emission.await()) + assertEquals(CommandResult.Success, safetyPause.await()) + + assertEquals(ExperimentState.PAUSED, runtime.snapshot.value.metadata?.state) + assertEquals( + TransitionReason.REQUIRED_ACCESS_MISSING, + runtime.snapshot.value.metadata?.transitions?.last()?.reason, + ) + assertEquals(SafetyPauseReason.REQUIRED_ACCESS_MISSING, runtime.snapshot.value.pendingSafetyPauseReason) + assertEquals(listOf(SafetyPauseReason.REQUIRED_ACCESS_MISSING), witness.persistedReasons) + } + + @Test + fun terminalCommandsWaitForAdmittedWritesAndAbortOnStorageFailure() = runTest { + val commands = listOf CommandResult>>( + "finish" to { finishEarly() }, + "duration" to { completeAfterDuration() }, + "withdraw" to { withdraw() }, + ) + commands.forEach { (name, command) -> + val store = InMemoryStudyStore() + val clocks = FakeClocks() + val collector = FakeCollectorPlugin(clocks) + val witness = RecordingSafetyPauseWitness() + val runtime = ExperimentRuntime( + configuration = configuration(), + store = store, + collectorRegistry = CollectorRegistry(listOf(collector)), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = witness, + ) + start(runtime) + store.appendEntered = CompletableDeferred() + store.releaseAppend = CompletableDeferred() + store.appendFailure = IllegalStateException("$name storage unavailable") + + val emission = async { collector.emit("ACTIVITY_RESUMED") } + store.appendEntered?.await() + val terminal = async { runtime.command() } + runCurrent() + assertFalse("$name committed before its admitted write drained", terminal.isCompleted) + + store.releaseAppend?.complete(Unit) + assertEquals(EmitResult.StorageFailure, emission.await()) + assertEquals(CommandResult.Failed("COMMAND_REJECTED"), terminal.await()) + + assertEquals(ExperimentState.RUNNING, runtime.snapshot.value.metadata?.state) + assertEquals(SafetyPauseReason.STORAGE_FAILURE, runtime.snapshot.value.pendingSafetyPauseReason) + assertTrue(runtime.snapshot.value.metadata?.transitions.orEmpty().none { + it.to in setOf(ExperimentState.COMPLETED, ExperimentState.WITHDRAWN) + }) + assertNull(collector.captureToken()) + assertEquals(listOf(SafetyPauseReason.STORAGE_FAILURE), witness.persistedReasons) + } + } + + @Test + fun cancelledAdmittedWriteCannotStrandTheTerminalDrainBarrier() = runTest { + val store = InMemoryStudyStore() + val clocks = FakeClocks() + val collector = FakeCollectorPlugin(clocks) + val witness = RecordingSafetyPauseWitness() + val runtime = ExperimentRuntime( + configuration = configuration(), + store = store, + collectorRegistry = CollectorRegistry(listOf(collector)), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = witness, + ) + start(runtime) + store.appendEntered = CompletableDeferred() + store.releaseAppend = CompletableDeferred() + + val emission = async { collector.emit("ACTIVITY_RESUMED") } + store.appendEntered?.await() + val finish = async { runtime.finishEarly() } + runCurrent() + assertFalse(finish.isCompleted) + + emission.cancelAndJoin() + runCurrent() + + assertEquals(CommandResult.Failed("COMMAND_REJECTED"), finish.await()) + assertEquals(ExperimentState.RUNNING, runtime.snapshot.value.metadata?.state) + assertEquals(SafetyPauseReason.STORAGE_FAILURE, runtime.snapshot.value.pendingSafetyPauseReason) + assertEquals(listOf(SafetyPauseReason.STORAGE_FAILURE), witness.persistedReasons) + } + + @Test + fun terminalCollectorStopFailureUsesTypedTeardownPauseAndCanBeRetried() = runTest { + val store = InMemoryStudyStore() + val clocks = FakeClocks() + val plugin = FakeCollectorPlugin(clocks) + val witness = RecordingSafetyPauseWitness() + val runtime = ExperimentRuntime( + configuration = configuration(), + store = store, + collectorRegistry = CollectorRegistry(listOf(plugin)), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = witness, + ) + start(runtime) + plugin.collector.failNextStopWithOwnedResources = true + + assertEquals(CommandResult.Failed("COMMAND_REJECTED"), runtime.finishEarly()) + + assertEquals(ExperimentState.RUNNING, runtime.snapshot.value.metadata?.state) + assertEquals( + SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE, + runtime.snapshot.value.pendingSafetyPauseReason, + ) + assertNull(plugin.captureToken()) + assertEquals( + listOf(SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE), + witness.persistedReasons, + ) + + assertEquals( + CommandResult.Success, + runtime.retrySafetyPause(SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE), + ) + assertTrue(runtime.acknowledgeSafetyPauseRequest(SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE)) + assertEquals(CommandResult.Success, runtime.finishEarly()) + assertEquals(ExperimentState.COMPLETED, runtime.snapshot.value.metadata?.state) + assertEquals(2, plugin.collector.stopCount) + } + + @Test + fun terminalTeardownFailureFromPausedPreservesTheEarlierParticipantPause() = runTest { + val terminalCommands = listOf CommandResult>>( + "finish" to { runtime -> runtime.finishEarly() }, + "withdraw" to { runtime -> runtime.withdraw() }, + ) + terminalCommands.forEach { (name, terminalCommand) -> + val store = InMemoryStudyStore() + val clocks = FakeClocks() + val plugin = FakeCollectorPlugin(clocks) + val witness = RecordingSafetyPauseWitness() + val runtime = ExperimentRuntime( + configuration = configuration(), + store = store, + collectorRegistry = CollectorRegistry(listOf(plugin)), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = witness, + ) + start(runtime) + assertEquals(name, CommandResult.Success, runtime.pause()) + val participantPause = requireNotNull(runtime.snapshot.value.metadata).transitions.last() + plugin.collector.failNextStopWithOwnedResources = true + + assertEquals(name, CommandResult.Failed("COMMAND_REJECTED"), terminalCommand(runtime)) + + val paused = requireNotNull(runtime.snapshot.value.metadata) + assertEquals(name, ExperimentState.PAUSED, paused.state) + assertEquals(name, participantPause, paused.transitions.last()) + assertEquals( + name, + SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE, + runtime.snapshot.value.pendingSafetyPauseReason, + ) + assertEquals( + name, + listOf(SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE), + witness.persistedReasons, + ) + assertEquals( + name, + CommandResult.Success, + runtime.retrySafetyPause(SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE), + ) + assertTrue( + name, + runtime.acknowledgeSafetyPauseRequest(SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE), + ) + assertEquals(name, participantPause, runtime.snapshot.value.metadata?.transitions?.last()) + } + } + + @Test + fun cancellingAParticipantOrTerminalDrainPersistsATypedTeardownWitness() = runTest { + data class CancelledDrain( + val name: String, + val blocksPause: Boolean, + val command: suspend (ExperimentRuntime) -> CommandResult, + ) + val commands = listOf( + CancelledDrain("pause", true) { runtime -> runtime.pause() }, + CancelledDrain("finish", false) { runtime -> runtime.finishEarly() }, + CancelledDrain("duration", false) { runtime -> runtime.completeAfterDuration() }, + CancelledDrain("withdraw", false) { runtime -> runtime.withdraw() }, + ) + commands.forEach { command -> + val clocks = FakeClocks() + val plugin = FakeCollectorPlugin(clocks) + val witness = RecordingSafetyPauseWitness() + val runtime = ExperimentRuntime( + configuration = configuration(), + store = InMemoryStudyStore(), + collectorRegistry = CollectorRegistry(listOf(plugin)), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = witness, + ) + start(runtime) + val teardownEntered = CompletableDeferred() + val blockUntilCancelled: suspend () -> Unit = { + teardownEntered.complete(Unit) + CompletableDeferred().await() + } + if (command.blocksPause) { + plugin.collector.beforePause = blockUntilCancelled + } else { + plugin.collector.beforeStop = blockUntilCancelled + } + + val drain = async { command.command(runtime) } + teardownEntered.await() + drain.cancelAndJoin() + + assertEquals(command.name, ExperimentState.RUNNING, runtime.snapshot.value.metadata?.state) + assertEquals( + command.name, + SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE, + runtime.snapshot.value.pendingSafetyPauseReason, + ) + assertEquals( + command.name, + listOf(SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE), + witness.persistedReasons, + ) + assertNull(command.name, plugin.captureToken()) + + plugin.collector.beforePause = {} + plugin.collector.beforeStop = {} + assertEquals( + command.name, + CommandResult.Success, + runtime.retrySafetyPause(SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE), + ) + assertTrue( + command.name, + runtime.acknowledgeSafetyPauseRequest(SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE), + ) + assertEquals(command.name, ExperimentState.PAUSED, runtime.snapshot.value.metadata?.state) + } + } + + @Test + fun unacknowledgedTransitionToRunningIsWitnessedAndRollsBackTheDurableFile() = runTest { + val startingStore = InMemoryStudyStore() + val startingWitness = RecordingSafetyPauseWitness() + val starting = ExperimentRuntime( + configuration = configuration(), + store = startingStore, + collectorRegistry = CollectorRegistry(listOf(FakeCollectorPlugin(FakeClocks()))), + clocks = FakeClocks(), + scope = backgroundScope, + safetyPauseWitness = startingWitness, + ) + assertEquals(CommandResult.Success, starting.initialize()) + assertEquals(CommandResult.Success, starting.reviewStudy()) + assertEquals(CommandResult.Success, starting.acceptConsent()) + assertEquals(CommandResult.Success, starting.completeAccessSetup(emptySet())) + startingStore.saveAfterCommitFailure = IllegalStateException("directory fsync was not acknowledged") + + assertEquals(CommandResult.Failed("COMMAND_REJECTED"), starting.start(emptySet())) + assertEquals(ExperimentState.READY, starting.snapshot.value.metadata?.state) + assertEquals(ExperimentState.RUNNING, startingStore.metadata?.state) + assertEquals(SafetyPauseReason.STORAGE_FAILURE, starting.snapshot.value.pendingSafetyPauseReason) + assertEquals(listOf(SafetyPauseReason.STORAGE_FAILURE), startingWitness.persistedReasons) + + assertEquals(CommandResult.Success, starting.retrySafetyPause(SafetyPauseReason.STORAGE_FAILURE)) + assertEquals(ExperimentState.READY, startingStore.metadata?.state) + assertTrue(starting.acknowledgeSafetyPauseRequest(SafetyPauseReason.STORAGE_FAILURE)) + + val resumingStore = InMemoryStudyStore() + val resumingWitness = RecordingSafetyPauseWitness() + val resuming = ExperimentRuntime( + configuration = configuration(), + store = resumingStore, + collectorRegistry = CollectorRegistry(listOf(FakeCollectorPlugin(FakeClocks()))), + clocks = FakeClocks(), + scope = backgroundScope, + safetyPauseWitness = resumingWitness, + ) + start(resuming) + assertEquals(CommandResult.Success, resuming.pause()) + val participantPause = resuming.snapshot.value.metadata?.transitions?.last() + resumingStore.saveAfterCommitFailure = IllegalStateException("resume commit was not acknowledged") + + assertEquals(CommandResult.Failed("COMMAND_REJECTED"), resuming.resume(emptySet())) + assertEquals(ExperimentState.PAUSED, resuming.snapshot.value.metadata?.state) + assertEquals(ExperimentState.RUNNING, resumingStore.metadata?.state) + assertEquals(SafetyPauseReason.STORAGE_FAILURE, resuming.snapshot.value.pendingSafetyPauseReason) + + assertEquals(CommandResult.Success, resuming.retrySafetyPause(SafetyPauseReason.STORAGE_FAILURE)) + assertEquals(ExperimentState.PAUSED, resumingStore.metadata?.state) + assertEquals(participantPause, resumingStore.metadata?.transitions?.last()) + assertTrue(resuming.acknowledgeSafetyPauseRequest(SafetyPauseReason.STORAGE_FAILURE)) + } + + @Test + fun cancellationIsNeverMisclassifiedAsAnIllegalCommand() = runTest { + val store = InMemoryStudyStore() + val witness = RecordingSafetyPauseWitness() + val runtime = ExperimentRuntime( + configuration = configuration(), + store = store, + collectorRegistry = CollectorRegistry(listOf(FakeCollectorPlugin(FakeClocks()))), + clocks = FakeClocks(), + scope = backgroundScope, + safetyPauseWitness = witness, + ) + assertEquals(CommandResult.Success, runtime.initialize()) + assertEquals(CommandResult.Success, runtime.reviewStudy()) + assertEquals(CommandResult.Success, runtime.acceptConsent()) + assertEquals(CommandResult.Success, runtime.completeAccessSetup(emptySet())) + val cancellation = CancellationException("caller cancelled the start") + store.saveAfterCommitFailure = cancellation + + val actual = runCatching { runtime.start(emptySet()) }.exceptionOrNull() + + assertTrue(actual === cancellation) + assertEquals(ExperimentState.READY, runtime.snapshot.value.metadata?.state) + assertEquals(ExperimentState.RUNNING, store.metadata?.state) + assertEquals(SafetyPauseReason.STORAGE_FAILURE, runtime.snapshot.value.pendingSafetyPauseReason) + assertEquals(listOf(SafetyPauseReason.STORAGE_FAILURE), witness.persistedReasons) + } + + @Test + fun participantPauseFailureUsesTypedTeardownPauseAndCannotReportSuccess() = runTest { + val store = InMemoryStudyStore() + val clocks = FakeClocks() + val plugin = FakeCollectorPlugin(clocks) + val witness = RecordingSafetyPauseWitness() + val runtime = ExperimentRuntime( + configuration = configuration(), + store = store, + collectorRegistry = CollectorRegistry(listOf(plugin)), + clocks = clocks, + scope = backgroundScope, + safetyPauseWitness = witness, + ) + start(runtime) + plugin.collector.failNextPauseWithOwnedResources = true + + assertEquals(CommandResult.Failed("COMMAND_REJECTED"), runtime.pause()) + + assertEquals(ExperimentState.PAUSED, runtime.snapshot.value.metadata?.state) + assertEquals( + TransitionReason.COLLECTION_TEARDOWN_FAILURE, + runtime.snapshot.value.metadata?.transitions?.last()?.reason, + ) + assertEquals( + SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE, + runtime.snapshot.value.pendingSafetyPauseReason, + ) + assertNull(plugin.captureToken()) + assertEquals( + listOf(SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE), + witness.persistedReasons, + ) + + assertEquals( + CommandResult.Success, + runtime.retrySafetyPause(SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE), + ) + assertTrue(runtime.acknowledgeSafetyPauseRequest(SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE)) + assertEquals(CommandResult.Success, runtime.resume(emptySet())) + assertEquals(2, plugin.collector.pauseCount) + assertEquals(1, plugin.collector.resumeCount) + } + @Test fun surveySubmissionValidatesEveryQuestionTypeAndCommitsExactlyOnce() = runTest { val store = InMemoryStudyStore() @@ -273,7 +1240,7 @@ class ExperimentRuntimeTest { collectorRegistry = CollectorRegistry(listOf(FakeCollectorPlugin(clocks))), clocks = clocks, scope = backgroundScope, - availableAccess = { emptySet() }, + safetyPauseWitness = RecordingSafetyPauseWitness(), ) start(runtime) val occurrence = InterventionOccurrence( @@ -327,7 +1294,7 @@ class ExperimentRuntimeTest { collectorRegistry = CollectorRegistry(listOf(FakeCollectorPlugin(clocks))), clocks = clocks, scope = backgroundScope, - availableAccess = { emptySet() }, + safetyPauseWitness = RecordingSafetyPauseWitness(), ) start(runtime) val occurrence = InterventionOccurrence( @@ -356,7 +1323,7 @@ class ExperimentRuntimeTest { collectorRegistry = CollectorRegistry(listOf(FakeCollectorPlugin(clocks))), clocks = clocks, scope = backgroundScope, - availableAccess = { emptySet() }, + safetyPauseWitness = RecordingSafetyPauseWitness(), ) start(runtime) val occurrence = surveyOccurrence("c", expiresAtUtcMillis = 5_000) @@ -388,7 +1355,7 @@ class ExperimentRuntimeTest { collectorRegistry = CollectorRegistry(listOf(FakeCollectorPlugin(clocks))), clocks = clocks, scope = backgroundScope, - availableAccess = { emptySet() }, + safetyPauseWitness = RecordingSafetyPauseWitness(), ) start(runtime) val occurrence = surveyOccurrence("9", scheduledAtUtcMillis = 3_000, expiresAtUtcMillis = 5_000) @@ -427,7 +1394,7 @@ class ExperimentRuntimeTest { collectorRegistry = CollectorRegistry(listOf(FakeCollectorPlugin(clocks))), clocks = clocks, scope = backgroundScope, - availableAccess = { emptySet() }, + safetyPauseWitness = RecordingSafetyPauseWitness(), ) start(runtime) val occurrences = listOf("d", "e", "f").mapIndexed { index, prefix -> @@ -470,7 +1437,7 @@ class ExperimentRuntimeTest { collectorRegistry = CollectorRegistry(listOf(FakeCollectorPlugin(clocks))), clocks = clocks, scope = backgroundScope, - availableAccess = { emptySet() }, + safetyPauseWitness = RecordingSafetyPauseWitness(), ) start(runtime) val posted = surveyOccurrence("1", expiresAtUtcMillis = 60_000) @@ -509,11 +1476,11 @@ class ExperimentRuntimeTest { collectorRegistry = CollectorRegistry(listOf(FakeCollectorPlugin(clocks))), clocks = clocks, scope = backgroundScope, - availableAccess = { emptySet() }, + safetyPauseWitness = RecordingSafetyPauseWitness(), ) runtime.initialize() - assertEquals(CommandResult.Failed("COMMAND_REJECTED"), runtime.start()) + assertEquals(CommandResult.Failed("COMMAND_REJECTED"), runtime.start(emptySet())) assertEquals(ExperimentState.IMPORTED, runtime.snapshot.value.metadata?.state) assertEquals(1, store.saveCount) @@ -538,7 +1505,7 @@ class ExperimentRuntimeTest { collectorRegistry = CollectorRegistry(listOf(FakeCollectorPlugin(clocks))), clocks = clocks, scope = backgroundScope, - availableAccess = { emptySet() }, + safetyPauseWitness = RecordingSafetyPauseWitness(), ) assertEquals(CommandResult.Success, runtime.initialize()) @@ -556,6 +1523,12 @@ class ExperimentRuntimeTest { var saveCount = 0 var usedBytes = 0L var quotaBytes = 16_777_216L + var appendFailure: Exception? = null + var recoverNextAppendFailClosed = false + var pendingRecoveredAppend: StudyMetadata? = null + var saveAfterCommitFailure: Throwable? = null + var appendEntered: CompletableDeferred? = null + var releaseAppend: CompletableDeferred? = null val evictionTargets = mutableListOf() override suspend fun storageUsage() = StorageUsage(usedBytes, quotaBytes) @@ -585,6 +1558,10 @@ class ExperimentRuntimeTest { override suspend fun saveMetadata(metadata: StudyMetadata) { this.metadata = metadata saveCount += 1 + saveAfterCommitFailure?.let { failure -> + saveAfterCommitFailure = null + throw failure + } } override suspend fun appendEvent(event: RecordedEvent) { @@ -599,13 +1576,47 @@ class ExperimentRuntimeTest { saveCount += 1 } - override suspend fun appendEventAtomically(event: RecordedEvent, metadata: StudyMetadata) { + override suspend fun appendEventAtomically( + event: RecordedEvent, + metadata: StudyMetadata, + failureTime: ResearchTime, + ) { + appendEntered?.complete(Unit) + releaseAppend?.await() + appendFailure?.let { throw it } require(event.sequenceNumber == requireNotNull(this.metadata).nextSequenceNumber) + if (recoverNextAppendFailClosed) { + recoverNextAppendFailClosed = false + events += event + val recovered = ExperimentStateMachine().transition( + metadata, + ExperimentState.PAUSED, + TransitionReason.STORAGE_FAILURE, + failureTime, + ) + this.metadata = recovered + pendingRecoveredAppend = recovered + throw StudyStoreMutationFailedClosed( + recovered, + IOException("injected acknowledged append failure"), + ) + } events += event this.metadata = metadata saveCount += 1 } + override suspend fun resolvePendingAppendFailure(reason: TransitionReason): StudyMetadata? { + val pending = pendingRecoveredAppend ?: return null + val transition = requireNotNull(pending.transitions.lastOrNull()) + val resolved = pending.copy( + transitions = pending.transitions.dropLast(1) + transition.copy(reason = reason), + ) + metadata = resolved + pendingRecoveredAppend = null + return resolved + } + override suspend fun readEvents( fromSequenceInclusive: Long, upToSequenceInclusive: Long, @@ -623,6 +1634,16 @@ class ExperimentRuntimeTest { } } + private class RecordingSafetyPauseWitness : SafetyPauseWitness { + val persistedReasons = mutableListOf() + var failure: Throwable? = null + + override suspend fun persist(reason: SafetyPauseReason) { + failure?.let { throw it } + persistedReasons += reason + } + } + private class FakeClocks : ResearchClocks { private var tick = 1L @@ -633,6 +1654,12 @@ class ExperimentRuntimeTest { ) } + private class ControlledClocks( + var current: ResearchTime, + ) : ResearchClocks { + override fun now(): ResearchTime = current + } + private class MutableClocks( var wallTimeUtcMillis: Long, ) : ResearchClocks { @@ -647,10 +1674,11 @@ class ExperimentRuntimeTest { private class FakeCollectorPlugin( private val clocks: ResearchClocks, - private val accessRequirement: AccessRequirement? = null, + accessKinds: Set = emptySet(), + collectorId: String = AppLifecycleConfiguration.ID, ) : CollectorPlugin { override val descriptor = CollectorDescriptor( - id = AppLifecycleConfiguration.ID, + id = collectorId, displayName = "Fake collector", privacyClass = PrivacyClass.SENSITIVE, eventContract = CollectorEventContract( @@ -663,32 +1691,40 @@ class ExperimentRuntimeTest { ) }, ), + accessKinds = accessKinds, ) lateinit var context: CollectorContext lateinit var collector: FakeCollector - override fun accessRequirements(configuration: CollectorConfiguration): Set { - require(configuration is AppLifecycleConfiguration) - return setOfNotNull(accessRequirement) - } - override fun create( configuration: CollectorConfiguration, context: CollectorContext, ): Collector { - require(configuration is AppLifecycleConfiguration) + require(configuration.id == descriptor.id) this.context = context collector = FakeCollector() return collector } + fun captureToken(): AdmissionToken? = context.eventSink.captureToken() + suspend fun emit( type: String, collectorId: String = descriptor.id, schemaVersion: Int = descriptor.payloadSchemaVersion, fields: Map = mapOf("source" to "test"), ): EmitResult { - val token = context.eventSink.captureToken() ?: return EmitResult.RejectedByAdmissionGate + val token = captureToken() ?: return EmitResult.RejectedByAdmissionGate + return emitWithToken(token, type, collectorId, schemaVersion, fields) + } + + suspend fun emitWithToken( + token: AdmissionToken, + type: String, + collectorId: String = descriptor.id, + schemaVersion: Int = descriptor.payloadSchemaVersion, + fields: Map = mapOf("source" to "test"), + ): EmitResult { return context.eventSink.emit( token, EventDraft( @@ -711,8 +1747,14 @@ class ExperimentRuntimeTest { var pauseCount = 0 var resumeCount = 0 var stopCount = 0 + var admissionOpenedCount = 0 var failNextStartWithOwnedResources = false + var failNextPauseWithOwnedResources = false + var failNextResumeWithOwnedResources = false var failNextStopWithOwnedResources = false + var beforePause: suspend () -> Unit = {} + var beforeStop: suspend () -> Unit = {} + var afterAdmissionOpened: suspend () -> Unit = {} override suspend fun start() { startCount += 1 @@ -725,18 +1767,33 @@ class ExperimentRuntimeTest { mutableHealth.value = CollectorHealth(CollectorStatus.ACTIVE) } + override suspend fun onAdmissionOpened() { + admissionOpenedCount += 1 + afterAdmissionOpened() + } + override suspend fun pause() { pauseCount += 1 + beforePause() + if (failNextPauseWithOwnedResources) { + failNextPauseWithOwnedResources = false + error("Pause left collector resources requiring cleanup") + } mutableHealth.value = CollectorHealth(CollectorStatus.PAUSED) } override suspend fun resume() { resumeCount += 1 + if (failNextResumeWithOwnedResources) { + failNextResumeWithOwnedResources = false + error("Resume did not establish a usable source") + } mutableHealth.value = CollectorHealth(CollectorStatus.ACTIVE) } override suspend fun stop() { stopCount += 1 + beforeStop() if (failNextStopWithOwnedResources) { failNextStopWithOwnedResources = false mutableHealth.value = CollectorHealth(CollectorStatus.FAILED, "SOURCE_UNREGISTRATION_FAILED") @@ -745,11 +1802,17 @@ class ExperimentRuntimeTest { requiresStop = false mutableHealth.value = CollectorHealth(CollectorStatus.STOPPED) } + + fun reportFailedWithOwnedResources() { + check(requiresStop) { "Collector must own a live source before reporting failure" } + mutableHealth.value = CollectorHealth(CollectorStatus.FAILED, "SOURCE_FAILED") + } } private companion object { const val EXPERIMENT_ID = "runtime-test" const val CONFIGURATION_ID = "runtime-config" + const val NANOS_PER_HOUR = 60L * 60L * 1_000_000_000L fun configuration( collectors: List = listOf(AppLifecycleConfiguration(required = true)), @@ -783,12 +1846,15 @@ class ExperimentRuntimeTest { upload = null, ) - suspend fun start(runtime: ExperimentRuntime) { + suspend fun start( + runtime: ExperimentRuntime, + availableAccess: Set = emptySet(), + ) { assertEquals(CommandResult.Success, runtime.initialize()) assertEquals(CommandResult.Success, runtime.reviewStudy()) assertEquals(CommandResult.Success, runtime.acceptConsent()) - assertEquals(CommandResult.Success, runtime.completeAccessSetup(emptySet())) - assertEquals(CommandResult.Success, runtime.start()) + assertEquals(CommandResult.Success, runtime.completeAccessSetup(availableAccess)) + assertEquals(CommandResult.Success, runtime.start(availableAccess)) } fun survey() = SurveyDefinition( diff --git a/core/export/src/test/kotlin/cool/jacoblin/particeps/core/export/ResearchExportTest.kt b/core/export/src/test/kotlin/cool/jacoblin/particeps/core/export/ResearchExportTest.kt index 7b4da77..5b73157 100644 --- a/core/export/src/test/kotlin/cool/jacoblin/particeps/core/export/ResearchExportTest.kt +++ b/core/export/src/test/kotlin/cool/jacoblin/particeps/core/export/ResearchExportTest.kt @@ -305,7 +305,14 @@ class ResearchExportTest { override suspend fun initialize(metadata: StudyMetadata) { this.metadata = metadata } override suspend fun saveMetadata(metadata: StudyMetadata) { this.metadata = metadata } override suspend fun appendEvent(event: RecordedEvent) = error("Not supported") - override suspend fun appendEventAtomically(event: RecordedEvent, metadata: StudyMetadata) = error("Not supported") + override suspend fun appendEventAtomically( + event: RecordedEvent, + metadata: StudyMetadata, + failureTime: ResearchTime, + ) = error("Not supported") + override suspend fun resolvePendingAppendFailure( + reason: cool.jacoblin.particeps.core.model.TransitionReason, + ): StudyMetadata? = null override suspend fun readEvents(fromSequenceInclusive: Long, upToSequenceInclusive: Long, consume: (RecordedEvent) -> Unit) { events.filter { it.sequenceNumber in fromSequenceInclusive..upToSequenceInclusive }.forEach(consume) diff --git a/core/model/src/main/kotlin/cool/jacoblin/particeps/core/model/ExperimentModels.kt b/core/model/src/main/kotlin/cool/jacoblin/particeps/core/model/ExperimentModels.kt index 966eed3..0fd519c 100644 --- a/core/model/src/main/kotlin/cool/jacoblin/particeps/core/model/ExperimentModels.kt +++ b/core/model/src/main/kotlin/cool/jacoblin/particeps/core/model/ExperimentModels.kt @@ -25,9 +25,24 @@ enum class TransitionReason( PARTICIPANT_FINISHED_EARLY(ExperimentState.COMPLETED), STUDY_DURATION_ELAPSED(ExperimentState.COMPLETED), PARTICIPANT_WITHDREW(ExperimentState.WITHDRAWN), + REQUIRED_ACCESS_MISSING(ExperimentState.PAUSED), + COLLECTION_HOST_FAILURE(ExperimentState.PAUSED), + WORK_SCHEDULING_FAILURE(ExperimentState.PAUSED), + COLLECTION_TEARDOWN_FAILURE(ExperimentState.PAUSED), STORAGE_FAILURE(ExperimentState.PAUSED), } +/** Closed reasons that force collection admission to remain closed until an explicit resume. */ +enum class SafetyPauseReason( + val transitionReason: TransitionReason, +) { + REQUIRED_ACCESS_MISSING(TransitionReason.REQUIRED_ACCESS_MISSING), + COLLECTION_HOST_FAILURE(TransitionReason.COLLECTION_HOST_FAILURE), + WORK_SCHEDULING_FAILURE(TransitionReason.WORK_SCHEDULING_FAILURE), + COLLECTION_TEARDOWN_FAILURE(TransitionReason.COLLECTION_TEARDOWN_FAILURE), + STORAGE_FAILURE(TransitionReason.STORAGE_FAILURE), +} + data class ResearchTime( val wallTimeUtcMillis: Long, val elapsedRealtimeNanos: Long, diff --git a/core/model/src/main/kotlin/cool/jacoblin/particeps/core/model/StudyData.kt b/core/model/src/main/kotlin/cool/jacoblin/particeps/core/model/StudyData.kt index d0625a9..005560e 100644 --- a/core/model/src/main/kotlin/cool/jacoblin/particeps/core/model/StudyData.kt +++ b/core/model/src/main/kotlin/cool/jacoblin/particeps/core/model/StudyData.kt @@ -152,8 +152,23 @@ interface StudyStore { suspend fun appendEvent(event: RecordedEvent) - /** Commits one event and its resulting metadata as a recoverable transaction. */ - suspend fun appendEventAtomically(event: RecordedEvent, metadata: StudyMetadata) + /** + * Commits one event and its resulting metadata as a recoverable transaction. [failureTime] + * pre-arms the exact fail-closed boundary before any journal or event byte is mutated. + */ + suspend fun appendEventAtomically( + event: RecordedEvent, + metadata: StudyMetadata, + failureTime: ResearchTime, + ) + + /** + * Resolves a fail-closed append journal that survived an uncertain mutation. Implementations + * return the authoritative PAUSED metadata when such a journal existed, or null when no append + * recovery is pending. [reason] must be the application-owned winning safety reason; resolving + * it is the only mutation allowed while that journal remains pending. + */ + suspend fun resolvePendingAppendFailure(reason: TransitionReason): StudyMetadata? /** * Streams `[fromSequenceInclusive, upToSequenceInclusive]`. Implementations must deliver the @@ -182,3 +197,13 @@ interface StudyStore { suspend fun clear() } + +/** + * A mutation failed after its fail-closed journal was acknowledged, and the store recovered a + * durable PAUSED boundary before returning. Runtimes must adopt [metadata] before reporting the + * original failure so they cannot reuse the pre-transaction sequence number in the same process. + */ +class StudyStoreMutationFailedClosed( + val metadata: StudyMetadata, + cause: Throwable, +) : java.io.IOException("Study-store mutation recovered fail-closed", cause) diff --git a/core/storage/src/androidTest/kotlin/cool/jacoblin/particeps/core/storage/EncryptedActiveStudyStoreTest.kt b/core/storage/src/androidTest/kotlin/cool/jacoblin/particeps/core/storage/EncryptedActiveStudyStoreTest.kt new file mode 100644 index 0000000..c35c301 --- /dev/null +++ b/core/storage/src/androidTest/kotlin/cool/jacoblin/particeps/core/storage/EncryptedActiveStudyStoreTest.kt @@ -0,0 +1,125 @@ +package cool.jacoblin.particeps.core.storage + +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import cool.jacoblin.particeps.core.protocol.ActiveStudyRecord +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class EncryptedActiveStudyStoreTest { + private val context = ApplicationProvider.getApplicationContext() + private val operations = FaultInjectingFileSystem() + private val store = EncryptedActiveStudyStore(context, operations) + + @Before + fun setUp() = runBlocking { + operations.failure = null + store.clear() + } + + @After + fun tearDown() = runBlocking { + operations.failure = null + store.clear() + } + + @Test + fun deletionTombstoneRenameFailureLeavesCrashEvidenceThatBlocksTheOldActiveStudy() = runBlocking { + val envelope = "signed-study-envelope".toByteArray() + store.save(envelope) + operations.failure = Failure.ATOMIC_REPLACE + + assertThrows(IOException::class.java) { + runBlocking { store.markDeletionPending("experiment-test", 16L * 1024 * 1024) } + } + + operations.failure = null + val reopened = EncryptedActiveStudyStore(context, operations) + assertThrows(IncompleteAtomicWrite::class.java) { + runBlocking { reopened.load() } + } + Unit + } + + @Test + fun activeStudyDeleteFailureCannotBeReportedAsClear() = runBlocking { + val envelope = "signed-study-envelope".toByteArray() + store.save(envelope) + operations.failure = Failure.DELETE + + assertThrows(IOException::class.java) { runBlocking { store.clear() } } + + operations.failure = null + val active = store.load() as ActiveStudyRecord.Active + assertArrayEquals(envelope, active.envelopeBytes) + } + + @Test + fun rc5FrameworkAtomicFileResidueBlocksActiveStudyRecovery() = runBlocking { + store.save("signed-study-envelope".toByteArray()) + context.noBackupFilesDir.resolve("active-study.ptc.new").writeText("unresolved rc.5 write") + + val reopened = EncryptedActiveStudyStore(context, operations) + + assertThrows(IncompleteAtomicWrite::class.java) { + runBlocking { reopened.load() } + } + Unit + } + + private enum class Failure { + ATOMIC_REPLACE, + DELETE, + } + + private class FaultInjectingFileSystem : AcknowledgedFileSystem { + var failure: Failure? = null + + override fun exists(file: File): Boolean = AndroidAcknowledgedFileSystem.exists(file) + + override fun isDirectory(file: File): Boolean = + AndroidAcknowledgedFileSystem.isDirectory(file) + + override fun listFiles(directory: File): Array? = + AndroidAcknowledgedFileSystem.listFiles(directory) + + override fun ensureDirectory(directory: File) = + AndroidAcknowledgedFileSystem.ensureDirectory(directory) + + override fun openOutput(file: File): FileOutputStream = + AndroidAcknowledgedFileSystem.openOutput(file) + + override fun syncFile(output: FileOutputStream) = + AndroidAcknowledgedFileSystem.syncFile(output) + + override fun closeFile(output: FileOutputStream) = + AndroidAcknowledgedFileSystem.closeFile(output) + + override fun atomicReplace(source: File, target: File) { + if (failure == Failure.ATOMIC_REPLACE) throw IOException("injected rename failure") + AndroidAcknowledgedFileSystem.atomicReplace(source, target) + } + + override fun readFully(file: File): ByteArray = + AndroidAcknowledgedFileSystem.readFully(file) + + override fun deleteIfExists(file: File) { + if (failure == Failure.DELETE && file.name == "active-study.ptc") { + throw IOException("injected delete failure") + } + AndroidAcknowledgedFileSystem.deleteIfExists(file) + } + + override fun syncDirectory(directory: File) = + AndroidAcknowledgedFileSystem.syncDirectory(directory) + } +} diff --git a/core/storage/src/androidTest/kotlin/cool/jacoblin/particeps/core/storage/EncryptedExperimentStoreTest.kt b/core/storage/src/androidTest/kotlin/cool/jacoblin/particeps/core/storage/EncryptedExperimentStoreTest.kt index ea5193d..b0c8500 100644 --- a/core/storage/src/androidTest/kotlin/cool/jacoblin/particeps/core/storage/EncryptedExperimentStoreTest.kt +++ b/core/storage/src/androidTest/kotlin/cool/jacoblin/particeps/core/storage/EncryptedExperimentStoreTest.kt @@ -2,11 +2,18 @@ package cool.jacoblin.particeps.core.storage import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import cool.jacoblin.particeps.core.model.StudyMetadata import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.ExperimentStateMachine import cool.jacoblin.particeps.core.model.RecordedEvent import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.StudyStoreMutationFailedClosed +import cool.jacoblin.particeps.core.model.TransitionReason import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.RandomAccessFile +import java.security.MessageDigest import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals @@ -98,6 +105,60 @@ class EncryptedExperimentStoreTest { ) } + @Test + fun partialEvictionCleanupCannotRollBackTheCommittedFloorOrCreateAGap() = runBlocking { + val partialExperimentId = "$experimentId-partial-eviction" + var deleteAttempts = 0 + val partialStore = EncryptedExperimentStore( + context = context, + experimentId = partialExperimentId, + maximumLocalBytes = QUOTA_BYTES, + deleteSegment = { file -> + deleteAttempts += 1 + deleteAttempts != 2 && file.delete() + }, + ) + try { + partialStore.clear() + partialStore.initialize( + StudyMetadata.initial(partialExperimentId, "encrypted-store-config") + .copy(state = ExperimentState.RUNNING), + ) + val padding = "x".repeat(PADDING_BYTES) + (1L..THREE_SEGMENTS_EVENT_COUNT).forEach { sequence -> + partialStore.appendEvent(event(sequence, mapOf("activity_class" to padding))) + } + val before = requireNotNull(partialStore.loadMetadata()) + val delivered = before.copy(uploadedThroughSequence = before.eventCount) + partialStore.saveMetadata(delivered) + + val committed = partialStore.evictThrough(delivered, targetBytes = 0) + + assertTrue("test requires a partial unlink", deleteAttempts >= 2) + assertTrue("the logical floor must commit before cleanup", committed.retainedFromSequence > 1) + assertThrows(IllegalArgumentException::class.java) { + runBlocking { partialStore.saveMetadata(delivered) } + } + + // The failed unlink stops later deletion, so reload sees a contiguous suffix and may + // conservatively lower the logical floor to the first segment that still exists. + val reloaded = requireNotNull(partialStore.loadMetadata()) + assertTrue(reloaded.retainedFromSequence in 2..committed.retainedFromSequence) + val events = mutableListOf() + partialStore.readEvents( + reloaded.retainedFromSequence, + reloaded.eventCount, + events::add, + ) + assertEquals( + (reloaded.retainedFromSequence..reloaded.eventCount).toList(), + events.map(RecordedEvent::sequenceNumber), + ) + } finally { + partialStore.clear() + } + } + @Test fun appendingAfterReclaimContinuesTheSequenceWithoutReuse() = runBlocking { store.initialize(runningMetadata()) @@ -208,6 +269,477 @@ class EncryptedExperimentStoreTest { assertTrue("a corrupt journal must remain available for diagnosis", transaction.exists()) } + @Test + fun metadataRenameFailureDoesNotAdvanceAuthoritativeInMemoryMetadata() = runBlocking { + val faultId = "$experimentId-metadata-fault" + val operations = TargetedFaultFileSystem() + val faultStore = EncryptedExperimentStore( + context = context, + experimentId = faultId, + maximumLocalBytes = QUOTA_BYTES, + deleteSegment = File::delete, + fileSystem = operations, + ) + try { + faultStore.clear() + val initial = StudyMetadata.initial(faultId, "encrypted-store-config") + .copy(state = ExperimentState.RUNNING) + faultStore.initialize(initial) + operations.renameFailureSuffix = ".metadata.ptc" + + assertThrows(IOException::class.java) { + runBlocking { + faultStore.saveMetadata(initial.copy(state = ExperimentState.PAUSED)) + } + } + + operations.renameFailureSuffix = null + val reopened = EncryptedExperimentStore( + context = context, + experimentId = faultId, + maximumLocalBytes = QUOTA_BYTES, + deleteSegment = File::delete, + fileSystem = operations, + ) + assertThrows(IncompleteAtomicWrite::class.java) { + runBlocking { reopened.loadMetadata() } + } + + // An explicit new store mutation is the only operation allowed to retire the pending + // evidence. It must use the old authoritative in-memory metadata, not the failed copy. + faultStore.appendEvent(event(1)) + assertEquals( + ExperimentState.RUNNING, + requireNotNull(faultStore.loadMetadata()).state, + ) + } finally { + operations.clearFailures() + faultStore.clear() + } + } + + @Test + fun journalRenameFailureDoesNotAdvanceTheSequenceBoundary() = runBlocking { + val faultId = "$experimentId-journal-fault" + val operations = TargetedFaultFileSystem() + val faultStore = EncryptedExperimentStore( + context = context, + experimentId = faultId, + maximumLocalBytes = QUOTA_BYTES, + deleteSegment = File::delete, + fileSystem = operations, + ) + try { + faultStore.clear() + faultStore.initialize( + StudyMetadata.initial(faultId, "encrypted-store-config") + .copy(state = ExperimentState.RUNNING), + ) + operations.renameFailureSuffix = ".transaction.ptc" + + assertThrows(IOException::class.java) { + runBlocking { faultStore.appendEvent(event(1)) } + } + + operations.renameFailureSuffix = null + val reopened = EncryptedExperimentStore( + context = context, + experimentId = faultId, + maximumLocalBytes = QUOTA_BYTES, + deleteSegment = File::delete, + fileSystem = operations, + ) + assertThrows(IncompleteAtomicWrite::class.java) { + runBlocking { reopened.loadMetadata() } + } + + assertThrows(IllegalStateException::class.java) { + runBlocking { faultStore.appendEvent(event(1)) } + } + } finally { + operations.clearFailures() + faultStore.clear() + } + Unit + } + + @Test + fun rc5FrameworkAtomicFileMetadataResidueBlocksRecovery() = runBlocking { + val legacyId = "$experimentId-rc5-metadata" + val legacyStore = EncryptedExperimentStore(context, legacyId, QUOTA_BYTES) + try { + legacyStore.clear() + legacyStore.initialize( + StudyMetadata.initial(legacyId, "encrypted-store-config") + .copy(state = ExperimentState.RUNNING), + ) + val metadata = experimentFile(legacyId, ".metadata.ptc") + requireNotNull(metadata.parentFile) + .resolve("${metadata.name}.new") + .writeText("unresolved rc.5 metadata") + + val reopened = EncryptedExperimentStore(context, legacyId, QUOTA_BYTES) + assertThrows(IncompleteAtomicWrite::class.java) { + runBlocking { reopened.loadMetadata() } + } + } finally { + legacyStore.clear() + } + Unit + } + + @Test + fun rc5FrameworkAtomicFileJournalResidueBlocksRecovery() = runBlocking { + val legacyId = "$experimentId-rc5-journal" + val legacyStore = EncryptedExperimentStore(context, legacyId, QUOTA_BYTES) + try { + legacyStore.clear() + legacyStore.initialize( + StudyMetadata.initial(legacyId, "encrypted-store-config") + .copy(state = ExperimentState.RUNNING), + ) + val metadata = experimentFile(legacyId, ".metadata.ptc") + requireNotNull(metadata.parentFile) + .resolve(metadata.name.replace(".metadata.ptc", ".transaction.ptc.new")) + .writeText("unresolved rc.5 journal") + + val reopened = EncryptedExperimentStore(context, legacyId, QUOTA_BYTES) + assertThrows(IncompleteAtomicWrite::class.java) { + runBlocking { reopened.loadMetadata() } + } + } finally { + legacyStore.clear() + } + Unit + } + + @Test + fun metadataPostRenameDirectorySyncFailureCannotReopenRunning() = runBlocking { + val faultId = "$experimentId-metadata-commit-sync" + val operations = TargetedFaultFileSystem() + val faultStore = EncryptedExperimentStore( + context = context, + experimentId = faultId, + maximumLocalBytes = QUOTA_BYTES, + deleteSegment = File::delete, + fileSystem = operations, + ) + try { + faultStore.clear() + val ready = StudyMetadata.initial(faultId, "encrypted-store-config") + .copy(state = ExperimentState.READY) + faultStore.initialize(ready) + operations.failRootDirectorySyncAt = operations.rootDirectorySyncAttempts + 2 + + assertThrows(IOException::class.java) { + runBlocking { faultStore.saveMetadata(ready.copy(state = ExperimentState.RUNNING)) } + } + + operations.failRootDirectorySyncAt = null + val reopened = EncryptedExperimentStore( + context = context, + experimentId = faultId, + maximumLocalBytes = QUOTA_BYTES, + deleteSegment = File::delete, + fileSystem = operations, + ) + assertThrows(IncompleteAtomicWrite::class.java) { + runBlocking { reopened.loadMetadata() } + } + } finally { + operations.clearFailures() + faultStore.clear() + } + Unit + } + + @Test + fun fullVisibleEventAfterSyncFailureRecoversDurablyPaused() = runBlocking { + verifyUnacknowledgedEventRecovery(writeCompleteFrame = true, resumeAndAppend = false) + } + + @Test + fun partialEventAfterWriteFailureRecoversDurablyPausedWithoutInventingTheEvent() = runBlocking { + verifyUnacknowledgedEventRecovery(writeCompleteFrame = false, resumeAndAppend = false) + } + + @Test + fun sameProcessFullEventFailureCanResumeWithoutReusingItsSequence() = runBlocking { + verifyUnacknowledgedEventRecovery(writeCompleteFrame = true, resumeAndAppend = true) + } + + @Test + fun sameProcessPartialEventFailureCanResumeWithoutReusingItsSequence() = runBlocking { + verifyUnacknowledgedEventRecovery(writeCompleteFrame = false, resumeAndAppend = true) + } + + @Test + fun retainedAppendJournalPreservesFirstSafetyReasonAcrossTwoProcessDeaths() = runBlocking { + val faultId = "$experimentId-double-reopen-first-reason" + val initial = StudyMetadata.initial(faultId, "encrypted-store-config") + .copy(state = ExperimentState.RUNNING) + val faultStore = EncryptedExperimentStore( + context = context, + experimentId = faultId, + maximumLocalBytes = QUOTA_BYTES, + deleteSegment = File::delete, + appendFrame = { file, frame -> + RandomAccessFile(file, "rw").use { output -> + output.seek(output.length()) + output.write(frame) + } + throw IOException("injected event acknowledgement failure") + }, + ) + try { + faultStore.clear() + faultStore.initialize(initial) + assertThrows(StudyStoreMutationFailedClosed::class.java) { + runBlocking { faultStore.appendEvent(event(1)) } + } + + val firstProcess = EncryptedExperimentStore(context, faultId, QUOTA_BYTES) + assertEquals(TransitionReason.STORAGE_FAILURE, firstProcess.loadMetadata()?.transitions?.last()?.reason) + val secondProcess = EncryptedExperimentStore(context, faultId, QUOTA_BYTES) + assertEquals(TransitionReason.STORAGE_FAILURE, secondProcess.loadMetadata()?.transitions?.last()?.reason) + val resolved = requireNotNull( + secondProcess.resolvePendingAppendFailure(TransitionReason.REQUIRED_ACCESS_MISSING), + ) + assertEquals(TransitionReason.REQUIRED_ACCESS_MISSING, resolved.transitions.last().reason) + + val finalProcess = EncryptedExperimentStore(context, faultId, QUOTA_BYTES) + assertEquals( + TransitionReason.REQUIRED_ACCESS_MISSING, + requireNotNull(finalProcess.loadMetadata()).transitions.last().reason, + ) + } finally { + faultStore.clear() + } + Unit + } + + @Test + fun failedInlineRecoveryKeepsEveryLaterMutationClosed() = runBlocking { + val faultId = "$experimentId-double-append-failure" + val initial = StudyMetadata.initial(faultId, "encrypted-store-config") + .copy(state = ExperimentState.RUNNING) + val operations = TargetedFaultFileSystem() + val faultStore = EncryptedExperimentStore( + context = context, + experimentId = faultId, + maximumLocalBytes = QUOTA_BYTES, + deleteSegment = File::delete, + fileSystem = operations, + appendFrame = { file, frame -> + RandomAccessFile(file, "rw").use { output -> + output.seek(output.length()) + output.write(frame.copyOf(frame.size / 2)) + } + operations.failRootDirectorySyncAt = operations.rootDirectorySyncAttempts + 1 + throw IOException("injected event write failure") + }, + ) + try { + faultStore.clear() + faultStore.initialize(initial) + assertThrows(IOException::class.java) { + runBlocking { faultStore.appendEvent(event(1)) } + } + operations.clearFailures() + + val requestedPause = ExperimentStateMachine().transition( + initial, + ExperimentState.PAUSED, + TransitionReason.STORAGE_FAILURE, + ResearchTime(9_000, 9_000, "boot-test"), + ) + val blockedSave = assertThrows(IllegalStateException::class.java) { + runBlocking { faultStore.saveMetadata(requestedPause) } + } + assertTrue(blockedSave.message.orEmpty().contains("requires fail-closed recovery")) + assertThrows(IllegalStateException::class.java) { + runBlocking { faultStore.appendEvent(event(1)) } + } + + val reopened = EncryptedExperimentStore( + context = context, + experimentId = faultId, + maximumLocalBytes = QUOTA_BYTES, + deleteSegment = File::delete, + fileSystem = operations, + ) + assertThrows(IncompleteAtomicWrite::class.java) { + runBlocking { reopened.loadMetadata() } + } + } finally { + operations.clearFailures() + faultStore.clear() + } + Unit + } + + @Test + fun evictionMetadataFailureCannotStartSegmentUnlink() = runBlocking { + val faultId = "$experimentId-eviction-fault" + val operations = TargetedFaultFileSystem() + var deleteAttempts = 0 + val faultStore = EncryptedExperimentStore( + context = context, + experimentId = faultId, + maximumLocalBytes = QUOTA_BYTES, + deleteSegment = { file -> + deleteAttempts += 1 + file.delete() + }, + fileSystem = operations, + ) + try { + faultStore.clear() + faultStore.initialize( + StudyMetadata.initial(faultId, "encrypted-store-config") + .copy(state = ExperimentState.RUNNING), + ) + val padding = "x".repeat(PADDING_BYTES) + (1L..THREE_SEGMENTS_EVENT_COUNT).forEach { sequence -> + faultStore.appendEvent(event(sequence, mapOf("activity_class" to padding))) + } + val before = requireNotNull(faultStore.loadMetadata()) + val delivered = before.copy(uploadedThroughSequence = before.eventCount) + faultStore.saveMetadata(delivered) + operations.renameFailureSuffix = ".metadata.ptc" + + assertThrows(IOException::class.java) { + runBlocking { faultStore.evictThrough(delivered, targetBytes = 0) } + } + + assertEquals(0, deleteAttempts) + } finally { + operations.clearFailures() + faultStore.clear() + } + Unit + } + + @Test + fun newEventSegmentRequiresDirectoryFsyncBeforeAppendCanSucceed() = runBlocking { + val faultId = "$experimentId-segment-directory-fault" + val operations = TargetedFaultFileSystem() + val faultStore = EncryptedExperimentStore( + context = context, + experimentId = faultId, + maximumLocalBytes = QUOTA_BYTES, + deleteSegment = File::delete, + fileSystem = operations, + ) + try { + faultStore.clear() + faultStore.initialize( + StudyMetadata.initial(faultId, "encrypted-store-config") + .copy(state = ExperimentState.RUNNING), + ) + operations.failEventDirectorySync = true + + assertThrows(IOException::class.java) { + runBlocking { faultStore.appendEvent(event(1)) } + } + + assertTrue(operations.eventDirectorySyncAttempts > 0) + operations.failEventDirectorySync = false + val reopened = EncryptedExperimentStore( + context = context, + experimentId = faultId, + maximumLocalBytes = QUOTA_BYTES, + deleteSegment = File::delete, + fileSystem = operations, + ) + assertThrows(IncompleteAtomicWrite::class.java) { + runBlocking { reopened.loadMetadata() } + } + } finally { + operations.clearFailures() + faultStore.clear() + } + Unit + } + + @Test + fun incompleteSegmentRenameEvidenceBlocksProcessDeathRecovery() = runBlocking { + val faultId = "$experimentId-segment-rename-fault" + val operations = TargetedFaultFileSystem() + val faultStore = EncryptedExperimentStore( + context = context, + experimentId = faultId, + maximumLocalBytes = QUOTA_BYTES, + deleteSegment = File::delete, + fileSystem = operations, + ) + try { + faultStore.clear() + faultStore.initialize( + StudyMetadata.initial(faultId, "encrypted-store-config") + .copy(state = ExperimentState.RUNNING), + ) + operations.renameFailureSuffix = ".ptcs" + + assertThrows(IOException::class.java) { + runBlocking { faultStore.appendEvent(event(1)) } + } + + operations.renameFailureSuffix = null + val reopened = EncryptedExperimentStore( + context = context, + experimentId = faultId, + maximumLocalBytes = QUOTA_BYTES, + deleteSegment = File::delete, + fileSystem = operations, + ) + assertThrows(IncompleteAtomicWrite::class.java) { + runBlocking { reopened.loadMetadata() } + } + Unit + } finally { + operations.clearFailures() + faultStore.clear() + } + } + + @Test + fun unlistableEventDirectoryBlocksFreshRecovery() = runBlocking { + val faultId = "$experimentId-listing-fault" + val operations = TargetedFaultFileSystem() + val faultStore = EncryptedExperimentStore( + context = context, + experimentId = faultId, + maximumLocalBytes = QUOTA_BYTES, + deleteSegment = File::delete, + fileSystem = operations, + ) + try { + faultStore.clear() + faultStore.initialize( + StudyMetadata.initial(faultId, "encrypted-store-config") + .copy(state = ExperimentState.RUNNING), + ) + faultStore.appendEvent(event(1)) + operations.failEventDirectoryListing = true + + val reopened = EncryptedExperimentStore( + context = context, + experimentId = faultId, + maximumLocalBytes = QUOTA_BYTES, + deleteSegment = File::delete, + fileSystem = operations, + ) + assertThrows(IllegalStateException::class.java) { + runBlocking { reopened.loadMetadata() } + } + Unit + } finally { + operations.clearFailures() + faultStore.clear() + } + } + private fun runningMetadata() = StudyMetadata.initial(experimentId, "encrypted-store-config") .copy(state = ExperimentState.RUNNING) @@ -239,8 +771,162 @@ class EncryptedExperimentStoreTest { private fun storageFiles() = File(context.noBackupFilesDir, "experiments").walkTopDown().filter(File::isFile).toList() + private fun experimentFile(experimentId: String, suffix: String): File { + val opaqueId = MessageDigest.getInstance("SHA-256") + .digest(experimentId.toByteArray(Charsets.UTF_8)) + .joinToString(separator = "") { byte -> "%02x".format(byte) } + return context.noBackupFilesDir.resolve("experiments/$opaqueId$suffix") + } + + private suspend fun verifyUnacknowledgedEventRecovery( + writeCompleteFrame: Boolean, + resumeAndAppend: Boolean, + ) { + val suffix = when { + resumeAndAppend && writeCompleteFrame -> "same-process-full-resume" + resumeAndAppend -> "same-process-partial-resume" + writeCompleteFrame -> "full-event-sync" + else -> "partial-event-write" + } + val faultId = "$experimentId-$suffix" + val initial = StudyMetadata.initial(faultId, "encrypted-store-config") + .copy(state = ExperimentState.RUNNING) + var failNextAppend = true + val faultStore = EncryptedExperimentStore( + context = context, + experimentId = faultId, + maximumLocalBytes = QUOTA_BYTES, + deleteSegment = File::delete, + appendFrame = { file, frame -> + if (failNextAppend) { + failNextAppend = false + RandomAccessFile(file, "rw").use { output -> + output.seek(output.length()) + output.write(if (writeCompleteFrame) frame else frame.copyOf(frame.size / 2)) + } + throw IOException("injected event acknowledgement failure") + } + RandomAccessFile(file, "rw").use { output -> + output.seek(output.length()) + output.write(frame) + output.fd.sync() + } + }, + ) + try { + faultStore.clear() + faultStore.initialize(initial) + assertThrows(StudyStoreMutationFailedClosed::class.java) { + runBlocking { faultStore.appendEvent(event(1)) } + } + + if (resumeAndAppend) { + val failClosed = requireNotNull( + faultStore.resolvePendingAppendFailure(TransitionReason.STORAGE_FAILURE), + ) + assertEquals(ExperimentState.PAUSED, failClosed.state) + assertEquals(TransitionReason.STORAGE_FAILURE, failClosed.transitions.last().reason) + assertEquals(if (writeCompleteFrame) 1L else 0L, failClosed.eventCount) + val resumed = ExperimentStateMachine().transition( + failClosed, + ExperimentState.RUNNING, + TransitionReason.PARTICIPANT_RESUMED, + ResearchTime(10_000, 10_000, "boot-test"), + ) + faultStore.saveMetadata(resumed) + faultStore.appendEvent(event(resumed.nextSequenceNumber)) + } + + val reopened = EncryptedExperimentStore(context, faultId, QUOTA_BYTES) + val recovered = requireNotNull(reopened.loadMetadata()) + val firstBoundary = if (writeCompleteFrame) 1L else 0L + val expectedBoundary = firstBoundary + if (resumeAndAppend) 1L else 0L + assertEquals(if (resumeAndAppend) ExperimentState.RUNNING else ExperimentState.PAUSED, recovered.state) + assertEquals(expectedBoundary, recovered.eventCount) + if (!resumeAndAppend) { + assertEquals(TransitionReason.STORAGE_FAILURE, recovered.transitions.last().reason) + } + if (expectedBoundary > 0) { + val events = mutableListOf() + reopened.readEvents(1, expectedBoundary, events::add) + assertEquals((1L..expectedBoundary).toList(), events.map(RecordedEvent::sequenceNumber)) + } + } finally { + faultStore.clear() + } + } + private fun segmentFiles() = storageFiles().filter { it.name.endsWith(".ptcs") } + private class TargetedFaultFileSystem : AcknowledgedFileSystem { + var renameFailureSuffix: String? = null + var failEventDirectorySync = false + var failEventDirectoryListing = false + var eventDirectorySyncAttempts = 0 + var rootDirectorySyncAttempts = 0 + var failRootDirectorySyncAt: Int? = null + + fun clearFailures() { + renameFailureSuffix = null + failEventDirectorySync = false + failEventDirectoryListing = false + failRootDirectorySyncAt = null + } + + override fun exists(file: File): Boolean = AndroidAcknowledgedFileSystem.exists(file) + + override fun isDirectory(file: File): Boolean = + AndroidAcknowledgedFileSystem.isDirectory(file) + + override fun listFiles(directory: File): Array? = + if (failEventDirectoryListing && directory.name.endsWith(".events")) { + null + } else { + AndroidAcknowledgedFileSystem.listFiles(directory) + } + + override fun ensureDirectory(directory: File) = + AndroidAcknowledgedFileSystem.ensureDirectory(directory) + + override fun openOutput(file: File): FileOutputStream = + AndroidAcknowledgedFileSystem.openOutput(file) + + override fun syncFile(output: FileOutputStream) = + AndroidAcknowledgedFileSystem.syncFile(output) + + override fun closeFile(output: FileOutputStream) = + AndroidAcknowledgedFileSystem.closeFile(output) + + override fun atomicReplace(source: File, target: File) { + if (renameFailureSuffix?.let(target.name::endsWith) == true) { + throw IOException("injected rename failure for ${target.name}") + } + AndroidAcknowledgedFileSystem.atomicReplace(source, target) + } + + override fun readFully(file: File): ByteArray = + AndroidAcknowledgedFileSystem.readFully(file) + + override fun deleteIfExists(file: File) = + AndroidAcknowledgedFileSystem.deleteIfExists(file) + + override fun syncDirectory(directory: File) { + if (directory.name == "experiments") { + rootDirectorySyncAttempts += 1 + if (rootDirectorySyncAttempts == failRootDirectorySyncAt) { + throw IOException("injected root-directory fsync failure") + } + } + if (directory.name.endsWith(".events")) { + eventDirectorySyncAttempts += 1 + if (failEventDirectorySync) { + throw IOException("injected event-directory fsync failure") + } + } + AndroidAcknowledgedFileSystem.syncDirectory(directory) + } + } + private companion object { const val QUOTA_BYTES = 64L * 1024 * 1024 const val PADDING_BYTES = 60 * 1024 diff --git a/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/AcknowledgedAtomicFile.kt b/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/AcknowledgedAtomicFile.kt new file mode 100644 index 0000000..1f5cf29 --- /dev/null +++ b/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/AcknowledgedAtomicFile.kt @@ -0,0 +1,246 @@ +package cool.jacoblin.particeps.core.storage + +import android.system.Os +import android.system.OsConstants +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.nio.file.FileAlreadyExistsException +import java.nio.file.Files + +/** + * Repo-owned atomic-file contract whose methods return only after the kernel-visible mutation is + * verified and its directory entry is durable. + * + * Android's `AtomicFile.finishWrite` logs several durability failures instead of propagating them. + * Safety and study stores cannot treat that as acknowledgement, so this implementation owns every + * step and has no weaker fallback path. + */ +interface AcknowledgedFile { + val baseFile: File + + fun exists(): Boolean + + fun readFully(): ByteArray + + fun write(bytes: ByteArray) + + fun delete() +} + +/** Durable evidence that a prior writer died or failed before the atomic replace was acknowledged. */ +class IncompleteAtomicWrite(file: File) : IOException( + "Unresolved atomic write for ${file.name}", +) + +class AcknowledgedAtomicFile internal constructor( + override val baseFile: File, + private val fileSystem: AcknowledgedFileSystem, +) : AcknowledgedFile { + constructor(baseFile: File) : this(baseFile, AndroidAcknowledgedFileSystem) + + private val stagedFile: File + get() = requireNotNull(baseFile.parentFile).resolve(".${baseFile.name}.pending") + + /** Separate rename source; [stagedFile] must survive until the parent-directory commit. */ + private val replacementFile: File + get() = requireNotNull(baseFile.parentFile).resolve(".${baseFile.name}.replacement") + + /** + * Android's framework AtomicFile used these names before this repository took ownership of the + * acknowledgement protocol. An in-place rc.5 -> rc.6 update can therefore encounter either + * artifact after a process death. They are evidence of an unresolved write, not alternate + * inputs that this implementation may guess how to promote. + */ + private val legacyStagedFiles: List + get() = listOf( + requireNotNull(baseFile.parentFile).resolve("${baseFile.name}.new"), + requireNotNull(baseFile.parentFile).resolve("${baseFile.name}.bak"), + ) + + private val unresolvedFiles: List + get() = listOf(stagedFile, replacementFile) + legacyStagedFiles + + override fun exists(): Boolean = fileSystem.exists(baseFile) || unresolvedFiles.any(fileSystem::exists) + + override fun readFully(): ByteArray { + if (unresolvedFiles.any(fileSystem::exists)) throw IncompleteAtomicWrite(baseFile) + return fileSystem.readFully(baseFile) + } + + override fun write(bytes: ByteArray) { + val parent = requireNotNull(baseFile.parentFile) { "Atomic file requires a parent directory" } + fileSystem.ensureDirectory(parent) + val unresolved = unresolvedFiles.filter(fileSystem::exists) + if (unresolved.isNotEmpty()) { + unresolved.forEach(fileSystem::deleteIfExists) + check(unresolved.none(fileSystem::exists)) { "Cannot retire an incomplete atomic write" } + fileSystem.syncDirectory(parent) + } + + // Keep one independently durable copy as an uncertainty witness. Renaming the only staged + // file would consume that evidence before the parent directory acknowledges the replace. + writeStaged(stagedFile, bytes) + writeStaged(replacementFile, bytes) + fileSystem.syncDirectory(parent) + fileSystem.atomicReplace(replacementFile, baseFile) + check(fileSystem.readFully(baseFile).contentEquals(bytes)) { + "Atomic-file readback did not match the acknowledged bytes" + } + fileSystem.syncDirectory(parent) + + // The base replacement is now acknowledged. Retiring the witness is cleanup rather than + // part of the commit: a cleanup failure may conservatively block a future reopen, but must + // never turn an acknowledged mutation into a reported failure that higher layers roll back. + try { + fileSystem.deleteIfExists(stagedFile) + check(!fileSystem.exists(stagedFile)) { "Cannot retire an acknowledged atomic write" } + fileSystem.syncDirectory(parent) + } catch (_: Exception) { + // Deliberately retained or ambiguously retired. A visible witness fails closed; if its + // deletion persisted, accepting the already-acknowledged base is also correct. + } + } + + private fun writeStaged( + target: File, + bytes: ByteArray, + ) { + var output: FileOutputStream? = null + var closeAttempted = false + try { + output = fileSystem.openOutput(target) + output.write(bytes) + fileSystem.syncFile(output) + closeAttempted = true + fileSystem.closeFile(output) + } catch (failure: Throwable) { + if (output != null && !closeAttempted) { + try { + fileSystem.closeFile(output) + } catch (closeFailure: Throwable) { + failure.addSuppressed(closeFailure) + } + } + throw failure + } + } + + override fun delete() { + val parent = requireNotNull(baseFile.parentFile) { "Atomic file requires a parent directory" } + if (!fileSystem.exists(parent)) { + check(!fileSystem.exists(baseFile) && unresolvedFiles.none(fileSystem::exists)) { + "Atomic-file parent is missing while its children remain visible" + } + return + } + + var firstFailure: Throwable? = null + (listOf(baseFile) + unresolvedFiles).forEach { file -> + try { + fileSystem.deleteIfExists(file) + check(!fileSystem.exists(file)) { "Atomic-file delete left ${file.name} visible" } + } catch (failure: Throwable) { + firstFailure = firstFailure.suppressing(failure) + } + } + try { + fileSystem.syncDirectory(parent) + } catch (failure: Throwable) { + firstFailure = firstFailure.suppressing(failure) + } + firstFailure?.let { throw it } + } +} + +internal interface AcknowledgedFileSystem { + fun exists(file: File): Boolean + + fun isDirectory(file: File): Boolean + + fun listFiles(directory: File): Array? + + fun ensureDirectory(directory: File) + + fun openOutput(file: File): FileOutputStream + + fun syncFile(output: FileOutputStream) + + fun closeFile(output: FileOutputStream) + + fun atomicReplace(source: File, target: File) + + fun readFully(file: File): ByteArray + + fun deleteIfExists(file: File) + + fun syncDirectory(directory: File) +} + +internal object AndroidAcknowledgedFileSystem : AcknowledgedFileSystem { + override fun exists(file: File): Boolean = file.exists() + + override fun isDirectory(file: File): Boolean = file.isDirectory + + override fun listFiles(directory: File): Array? = directory.listFiles() + + override fun ensureDirectory(directory: File) { + if (directory.exists()) { + require(directory.isDirectory) { "Atomic-file parent is not a directory" } + return + } + val parent = requireNotNull(directory.parentFile) { "Directory requires a parent" } + require(parent.isDirectory) { "Directory parent does not exist" } + try { + Files.createDirectory(directory.toPath()) + } catch (_: FileAlreadyExistsException) { + require(directory.isDirectory) { "Atomic-file parent is not a directory" } + return + } + syncDirectory(parent) + } + + override fun openOutput(file: File): FileOutputStream = FileOutputStream(file, false) + + override fun syncFile(output: FileOutputStream) { + output.fd.sync() + } + + override fun closeFile(output: FileOutputStream) { + output.close() + } + + override fun atomicReplace(source: File, target: File) { + Os.rename(source.absolutePath, target.absolutePath) + } + + override fun readFully(file: File): ByteArray = Files.readAllBytes(file.toPath()) + + override fun deleteIfExists(file: File) { + Files.deleteIfExists(file.toPath()) + } + + override fun syncDirectory(directory: File) { + require(directory.isDirectory) { "Cannot sync a missing directory" } + val descriptor = Os.open( + directory.absolutePath, + OsConstants.O_RDONLY or OsConstants.O_CLOEXEC, + 0, + ) + var failure: Throwable? = null + try { + Os.fsync(descriptor) + } catch (syncFailure: Throwable) { + failure = syncFailure + } + try { + Os.close(descriptor) + } catch (closeFailure: Throwable) { + failure = failure.suppressing(closeFailure) + } + failure?.let { throw it } + } +} + +private fun Throwable?.suppressing(failure: Throwable): Throwable = + this?.also { it.addSuppressed(failure) } ?: failure diff --git a/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/AppendTransactionRecovery.kt b/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/AppendTransactionRecovery.kt index c831054..ce14d80 100644 --- a/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/AppendTransactionRecovery.kt +++ b/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/AppendTransactionRecovery.kt @@ -1,14 +1,26 @@ package cool.jacoblin.particeps.core.storage import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.ExperimentTransition import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.TransitionReason internal data class AppendRecoveryResult( val metadata: StudyMetadata, val rewriteMetadata: Boolean, + val failureResolutionRequired: Boolean, ) -/** Resolves the one-event write-ahead transaction against the durable event tail. */ +/** + * Resolves a pre-armed one-event transaction against the durable event tail. + * + * The transaction is deliberately the fail-closed successor: it already contains the proposed + * event/metadata side effects and a final `RUNNING -> PAUSED / STORAGE_FAILURE` transition. A + * process death anywhere after this journal is acknowledged therefore recovers PAUSED, whether the + * event was absent, truncated, or fully durable. Only retiring the journal after the main successor + * commit lets a later process regard that append as an ordinary RUNNING commit. + */ internal object AppendTransactionRecovery { fun recover( main: StudyMetadata, @@ -23,39 +35,150 @@ internal object AppendTransactionRecovery { "Durable event tail has no matching append transaction" } require(durableTail == null) { "Unexpected recovery tail without a transaction" } - return AppendRecoveryResult(main, rewriteMetadata = false) + return AppendRecoveryResult( + metadata = main, + rewriteMetadata = false, + failureResolutionRequired = false, + ) } - validateLastEvents(transaction) - if (transaction.eventCount == main.eventCount) { - validateImmutableIdentity(main, transaction) - require(durableLastSequence == main.eventCount) { "Committed transaction boundary mismatch" } - require(durableTail == null) { "Same-boundary transaction must not carry a recovery tail" } - return AppendRecoveryResult(main, rewriteMetadata = false) - } + val failureTransition = validateFailureTransaction(transaction) + val successor = transaction.copy( + state = ExperimentState.RUNNING, + transitions = transaction.transitions.dropLast(1), + ) + validateLastEvents(successor) + val appended = successor.lastEvents.values.singleOrNull { + it.sequenceNumber == successor.eventCount + } ?: error("Append transaction does not identify exactly one newest event") - require(transaction.eventCount == main.eventCount + 1) { - "Append transaction is not a one-event successor" + return when (main.eventCount) { + successor.eventCount - 1 -> recoverAcrossEventBoundary( + main = main, + successor = successor, + failClosedSuccessor = transaction, + failureTransition = failureTransition, + appended = appended, + durableLastSequence = durableLastSequence, + durableTail = durableTail, + ) + successor.eventCount -> recoverAtCommittedBoundary( + main = main, + successor = successor, + failClosedSuccessor = transaction, + appended = appended, + durableLastSequence = durableLastSequence, + durableTail = durableTail, + ) + else -> error("Append transaction is outside the main metadata boundary") } - validateStableMetadata(main, transaction) - val appended = transaction.lastEvents.values.singleOrNull { - it.sequenceNumber == transaction.eventCount - } ?: error("Append transaction does not identify exactly one newest event") - require(transaction.lastEvents == main.lastEvents + (appended.collectorId to appended)) { + } + + private fun recoverAcrossEventBoundary( + main: StudyMetadata, + successor: StudyMetadata, + failClosedSuccessor: StudyMetadata, + failureTransition: ExperimentTransition, + appended: RecordedEvent, + durableLastSequence: Long, + durableTail: RecordedEvent?, + ): AppendRecoveryResult { + val runningMain = runningBoundaryBeforePause(main) + validateStableMetadata(runningMain, successor) + require(successor.lastEvents == runningMain.lastEvents + (appended.collectorId to appended)) { "Append transaction rewrites unrelated latest events" } - return when (durableLastSequence) { - main.eventCount -> { + val recovered = when (durableLastSequence) { + runningMain.eventCount -> { require(durableTail == null) { "Non-durable transaction has an event tail" } - AppendRecoveryResult(main, rewriteMetadata = false) + if (main.state == ExperimentState.PAUSED) { + main + } else { + main.copy( + state = ExperimentState.PAUSED, + transitions = main.transitions + failureTransition, + ) + } } - transaction.eventCount -> { + successor.eventCount -> { require(durableTail == appended) { "Append transaction does not match the durable event tail" } - AppendRecoveryResult(transaction, rewriteMetadata = true) + if (main.state == ExperimentState.PAUSED) { + successor.copy( + state = ExperimentState.PAUSED, + transitions = main.transitions, + ) + } else { + failClosedSuccessor + } } else -> error("Durable event tail is outside the append transaction boundary") } + return AppendRecoveryResult( + metadata = recovered, + rewriteMetadata = recovered != main, + failureResolutionRequired = true, + ) + } + + private fun recoverAtCommittedBoundary( + main: StudyMetadata, + successor: StudyMetadata, + failClosedSuccessor: StudyMetadata, + appended: RecordedEvent, + durableLastSequence: Long, + durableTail: RecordedEvent?, + ): AppendRecoveryResult { + validateImmutableIdentity(main, successor) + require(main.nextSequenceNumber == successor.nextSequenceNumber) { + "Committed append transaction next sequence changed" + } + require(main.lastEvents == successor.lastEvents) { + "Committed append transaction latest events changed" + } + require(durableLastSequence == successor.eventCount) { "Committed transaction boundary mismatch" } + require(durableTail == appended) { "Committed transaction does not match the durable event tail" } + + val unresolvedFailure = main == successor || main == failClosedSuccessor + val recovered = if (unresolvedFailure) { + failClosedSuccessor + } else { + main // A later durable mutation proves the append returned and the journal is stale. + } + return AppendRecoveryResult( + metadata = recovered, + rewriteMetadata = recovered != main, + failureResolutionRequired = unresolvedFailure, + ) + } + + private fun validateFailureTransaction(transaction: StudyMetadata): ExperimentTransition { + validateLastEvents(transaction) + require(transaction.state == ExperimentState.PAUSED) { "Append transaction is not fail-closed" } + val transition = transaction.transitions.lastOrNull() + ?: error("Append transaction has no fail-closed transition") + require( + transition.from == ExperimentState.RUNNING && + transition.to == ExperimentState.PAUSED && + transition.reason == TransitionReason.STORAGE_FAILURE, + ) { "Append transaction has an invalid fail-closed transition" } + return transition + } + + private fun runningBoundaryBeforePause(metadata: StudyMetadata): StudyMetadata = when (metadata.state) { + ExperimentState.RUNNING -> metadata + ExperimentState.PAUSED -> { + val transition = metadata.transitions.lastOrNull() + ?: error("Paused main metadata has no transition") + require(transition.from == ExperimentState.RUNNING && transition.to == ExperimentState.PAUSED) { + "Paused main metadata is not the append failure boundary" + } + metadata.copy( + state = ExperimentState.RUNNING, + transitions = metadata.transitions.dropLast(1), + ) + } + else -> error("Append transaction main metadata is not active") } private fun validateLastEvents(metadata: StudyMetadata) { diff --git a/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/EncryptedActiveStudyStore.kt b/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/EncryptedActiveStudyStore.kt index fdabd3e..6b363ed 100644 --- a/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/EncryptedActiveStudyStore.kt +++ b/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/EncryptedActiveStudyStore.kt @@ -3,7 +3,6 @@ package cool.jacoblin.particeps.core.storage import android.content.Context import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties -import android.util.AtomicFile import cool.jacoblin.particeps.core.protocol.ActiveStudyRecord import cool.jacoblin.particeps.core.protocol.ActiveStudyStore import java.nio.ByteBuffer @@ -17,16 +16,31 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -class EncryptedActiveStudyStore( - context: Context, +class EncryptedActiveStudyStore private constructor( + private val atomicFile: AcknowledgedFile, + private val keyStore: KeyStore, ) : ActiveStudyStore { + constructor(context: Context) : this( + atomicFile = AcknowledgedAtomicFile(context.noBackupFilesDir.resolve(FILE_NAME)), + keyStore = androidKeyStore(), + ) + + internal constructor( + context: Context, + fileSystem: AcknowledgedFileSystem, + ) : this( + atomicFile = AcknowledgedAtomicFile( + context.noBackupFilesDir.resolve(FILE_NAME), + fileSystem, + ), + keyStore = androidKeyStore(), + ) + private val mutex = Mutex() - private val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } - private val atomicFile = AtomicFile(context.noBackupFilesDir.resolve(FILE_NAME)) override suspend fun load(): ActiveStudyRecord? = withContext(Dispatchers.IO) { mutex.withLock { - if (!atomicFile.baseFile.exists()) return@withLock null + if (!atomicFile.exists()) return@withLock null val bytes = atomicFile.readFully() require(bytes.size in MINIMUM_ENCODED_BYTES..MAXIMUM_ENCODED_BYTES) { "Encrypted active-study file has an invalid size" @@ -50,7 +64,7 @@ class EncryptedActiveStudyStore( ) = withContext(Dispatchers.IO) { val deletion = ActiveStudyRecord.DeletionPending(experimentId, maximumLocalBytes) mutex.withLock { - require(atomicFile.baseFile.exists()) { "No active study to delete" } + require(atomicFile.exists()) { "No active study to delete" } val key = keyStore.getKey(KEY_ALIAS, null) as? SecretKey ?: error("Active-study encryption key is unavailable") val id = deletion.experimentId.toByteArray(Charsets.US_ASCII) @@ -90,14 +104,7 @@ class EncryptedActiveStudyStore( private fun writeRecord(plaintext: ByteArray, key: SecretKey) { val encrypted = encrypt(plaintext, key) - val output = atomicFile.startWrite() - try { - output.write(encrypted) - atomicFile.finishWrite(output) - } catch (failure: Throwable) { - atomicFile.failWrite(output) - throw failure - } + atomicFile.write(encrypted) } private fun decodeRecord(plaintext: ByteArray): ActiveStudyRecord { @@ -155,6 +162,8 @@ class EncryptedActiveStudyStore( .generateKey() private companion object { + fun androidKeyStore(): KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + const val ANDROID_KEYSTORE = "AndroidKeyStore" const val CIPHER = "AES/GCM/NoPadding" const val KEY_ALIAS = "particeps-active-study-v1" diff --git a/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/EncryptedExperimentStore.kt b/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/EncryptedExperimentStore.kt index 380dee5..390eead 100644 --- a/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/EncryptedExperimentStore.kt +++ b/core/storage/src/main/kotlin/cool/jacoblin/particeps/core/storage/EncryptedExperimentStore.kt @@ -3,11 +3,15 @@ package cool.jacoblin.particeps.core.storage import android.content.Context import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties -import android.util.AtomicFile import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.ExperimentStateMachine +import cool.jacoblin.particeps.core.model.ResearchTime import cool.jacoblin.particeps.core.model.StorageUsage import cool.jacoblin.particeps.core.model.StudyMetadata import cool.jacoblin.particeps.core.model.StudyStore +import cool.jacoblin.particeps.core.model.StudyStoreMutationFailedClosed +import cool.jacoblin.particeps.core.model.TransitionReason import java.io.File import java.io.RandomAccessFile import java.nio.ByteBuffer @@ -22,22 +26,45 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -class EncryptedExperimentStore( +class EncryptedExperimentStore internal constructor( context: Context, private val experimentId: String, private val maximumLocalBytes: Long, + private val deleteSegment: (File) -> Boolean, + private val fileSystem: AcknowledgedFileSystem = AndroidAcknowledgedFileSystem, + private val appendFrame: (File, ByteArray) -> Unit = ::appendFrameDurably, ) : StudyStore { + constructor( + context: Context, + experimentId: String, + maximumLocalBytes: Long, + ) : this( + context, + experimentId, + maximumLocalBytes, + File::delete, + AndroidAcknowledgedFileSystem, + ) + private val mutex = Mutex() + private val stateMachine = ExperimentStateMachine() private val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } private val opaqueId = sha256(experimentId.toByteArray(Charsets.UTF_8)).toHex() private val keyAlias = "particeps-core-$opaqueId" private val rootDirectory = context.noBackupFilesDir.resolve(STORAGE_DIRECTORY) - private val metadataFile = AtomicFile(rootDirectory.resolve("$opaqueId.metadata.ptc")) - private val transactionFile = AtomicFile(rootDirectory.resolve("$opaqueId.transaction.ptc")) + private val metadataFile = AcknowledgedAtomicFile( + rootDirectory.resolve("$opaqueId.metadata.ptc"), + fileSystem, + ) + private val transactionFile = AcknowledgedAtomicFile( + rootDirectory.resolve("$opaqueId.transaction.ptc"), + fileSystem, + ) private val eventDirectory = rootDirectory.resolve("$opaqueId.events") private var persistedSequenceBoundary = 0L private var persistedRetainedFrom = 1L private var persistedMetadata: StudyMetadata? = null + private var appendRecoveryRequired = false init { require(maximumLocalBytes in MINIMUM_LOCAL_BYTES..MAXIMUM_LOCAL_BYTES) { "Invalid storage quota" } @@ -45,10 +72,13 @@ class EncryptedExperimentStore( override suspend fun loadMetadata(): StudyMetadata? = withContext(Dispatchers.IO) { mutex.withLock { - if (!metadataFile.baseFile.exists()) { - require(eventDirectory.listFiles().isNullOrEmpty()) { "Event segments exist without metadata" } + appendRecoveryRequired = transactionFile.exists() + if (!metadataFile.exists()) { + require(eventDirectoryEntries().isEmpty()) { "Event segments exist without metadata" } + require(!appendRecoveryRequired) { "Append transaction exists without metadata" } persistedMetadata = null persistedSequenceBoundary = 0 + persistedRetainedFrom = 1 return@withLock null } val key = existingKey() ?: error("Encrypted experiment key is unavailable") @@ -65,7 +95,7 @@ class EncryptedExperimentStore( val mainMetadata = StudyDataJsonCodec.decodeMetadata( decryptMetadata(metadataFile.readFully(), key), ) - val hasTransaction = transactionFile.baseFile.exists() + val hasTransaction = appendRecoveryRequired val transactionMetadata = if (hasTransaction) { StudyDataJsonCodec.decodeMetadata( decryptDocument(transactionFile.readFully(), key, TRANSACTION_HEADER), @@ -74,9 +104,7 @@ class EncryptedExperimentStore( null } val durableTail = if ( - transactionMetadata != null && - transactionMetadata.eventCount == mainMetadata.eventCount + 1 && - transactionMetadata.eventCount == scan.lastSequence + transactionMetadata != null && transactionMetadata.eventCount == scan.lastSequence ) { readDurableTail(key, scan.lastSequence) } else { @@ -96,8 +124,8 @@ class EncryptedExperimentStore( if (recovery.rewriteMetadata || metadata != recovery.metadata) { writeMetadata(encryptDocument(StudyDataJsonCodec.encodeMetadata(metadata), key, METADATA_HEADER)) } - if (hasTransaction) { - transactionFile.delete() + if (hasTransaction && !recovery.failureResolutionRequired) { + retireTransactionAfterCommit() } require(metadata.experimentId == experimentId) { "Encrypted experiment ID mismatch" } // The lifetime counter comes from metadata, not from the scan: reclaimed events are @@ -105,13 +133,18 @@ class EncryptedExperimentStore( persistedSequenceBoundary = metadata.eventCount persistedRetainedFrom = metadata.retainedFromSequence persistedMetadata = metadata + appendRecoveryRequired = recovery.failureResolutionRequired metadata } } override suspend fun initialize(metadata: StudyMetadata) = withContext(Dispatchers.IO) { mutex.withLock { - require(!metadataFile.baseFile.exists() && eventDirectory.listFiles().isNullOrEmpty()) { + require( + !metadataFile.exists() && + !transactionFile.exists() && + eventDirectoryEntries().isEmpty(), + ) { "Study storage is already initialized" } require(metadata.experimentId == experimentId) { "Experiment ID mismatch" } @@ -122,15 +155,20 @@ class EncryptedExperimentStore( override suspend fun saveMetadata(metadata: StudyMetadata) = withContext(Dispatchers.IO) { mutex.withLock { + requireNoPendingAppendRecovery() requireNotNull(persistedMetadata) { "Study storage is not initialized" } require(metadata.experimentId == experimentId) { "Experiment ID mismatch" } require(metadata.eventCount == persistedSequenceBoundary) { "Metadata event boundary changed" } + require(metadata.retainedFromSequence >= persistedRetainedFrom) { + "Metadata retained floor cannot move behind durable storage" + } persistMetadata(metadata, existingKey() ?: error("Encrypted experiment key is unavailable")) } } override suspend fun appendEvent(event: RecordedEvent) = withContext(Dispatchers.IO) { mutex.withLock { + requireNoPendingAppendRecovery() val metadata = requireNotNull(persistedMetadata) { "Study storage is not initialized" } appendTransaction( event, @@ -140,37 +178,152 @@ class EncryptedExperimentStore( lastEvents = metadata.lastEvents + (event.collectorId to event), retainedFromSequence = persistedRetainedFrom, ), + event.observedTime, ) } } - override suspend fun appendEventAtomically(event: RecordedEvent, metadata: StudyMetadata) = - withContext(Dispatchers.IO) { mutex.withLock { appendTransaction(event, metadata) } } + override suspend fun appendEventAtomically( + event: RecordedEvent, + metadata: StudyMetadata, + failureTime: ResearchTime, + ) = withContext(Dispatchers.IO) { + mutex.withLock { + requireNoPendingAppendRecovery() + appendTransaction(event, metadata, failureTime) + } + } + + override suspend fun resolvePendingAppendFailure(reason: TransitionReason): StudyMetadata? = + withContext(Dispatchers.IO) { + mutex.withLock { + if (!appendRecoveryRequired) return@withLock null + require(reason in SAFETY_PAUSE_TRANSITION_REASONS) { + "Append recovery requires a closed safety-pause reason" + } + val current = requireNotNull(persistedMetadata) { + "Study storage is not initialized" + } + require(current.state == ExperimentState.PAUSED) { + "Pending append recovery is not paused" + } + val failure = current.transitions.lastOrNull() + ?: error("Pending append recovery has no transition") + require( + failure.from == ExperimentState.RUNNING && + failure.to == ExperimentState.PAUSED && + failure.reason == TransitionReason.STORAGE_FAILURE, + ) { "Pending append recovery has no synthetic storage transition" } + val resolved = if (reason == TransitionReason.STORAGE_FAILURE) { + current + } else { + current.copy( + transitions = current.transitions.dropLast(1) + failure.copy(reason = reason), + ) + } + if (resolved != current) writeMetadataDocument(resolved, existingKeyOrThrow()) + commitAuthoritativeMetadata(resolved) + retireTransactionAfterCommit() + appendRecoveryRequired = false + resolved + } + } - private fun appendTransaction(event: RecordedEvent, metadata: StudyMetadata) { + private fun appendTransaction( + event: RecordedEvent, + metadata: StudyMetadata, + failureTime: ResearchTime, + ) { val current = requireNotNull(persistedMetadata) { "Study storage is not initialized" } require(event.sequenceNumber == persistedSequenceBoundary + 1) { "Non-contiguous event append" } require(metadata.eventCount == event.sequenceNumber && metadata.nextSequenceNumber == event.sequenceNumber + 1) { "Atomic metadata boundary mismatch" } require(metadata.experimentId == experimentId) { "Experiment ID mismatch" } + val key = existingKey() ?: error("Encrypted experiment key is unavailable") + val encoded = StudyDataJsonCodec.encodeMetadata(metadata) + require(encoded.size <= MAXIMUM_METADATA_BYTES) { "Experiment metadata quota exceeded" } + val failClosedMetadata = stateMachine.transition( + metadata, + ExperimentState.PAUSED, + TransitionReason.STORAGE_FAILURE, + failureTime, + ) val validated = AppendTransactionRecovery.recover( main = current, - transaction = metadata, + transaction = failClosedMetadata, durableLastSequence = event.sequenceNumber, durableTail = event, ) - check(validated.rewriteMetadata && validated.metadata == metadata) { + check(validated.rewriteMetadata && validated.metadata == failClosedMetadata) { "Atomic append metadata is not a valid one-event successor" } - val key = existingKey() ?: error("Encrypted experiment key is unavailable") - val encoded = StudyDataJsonCodec.encodeMetadata(metadata) - require(encoded.size <= MAXIMUM_METADATA_BYTES) { "Experiment metadata quota exceeded" } - writeAtomic(transactionFile, encryptDocument(encoded, key, TRANSACTION_HEADER)) - appendEncryptedEvent(event, key) - persistedSequenceBoundary = event.sequenceNumber - persistMetadata(metadata, key) - transactionFile.delete() + val failClosedEncoded = StudyDataJsonCodec.encodeMetadata(failClosedMetadata) + require(failClosedEncoded.size <= MAXIMUM_METADATA_BYTES) { + "Fail-closed append metadata quota exceeded" + } + appendRecoveryRequired = true + try { + transactionFile.write(encryptDocument(failClosedEncoded, key, TRANSACTION_HEADER)) + } catch (failure: Throwable) { + if (!transactionFile.exists()) appendRecoveryRequired = false + throw failure + } + try { + appendEncryptedEvent(event, key) + writeMetadataDocument(metadata, key) + commitAuthoritativeMetadata(metadata) + retireTransactionAfterCommit() + appendRecoveryRequired = false + } catch (failure: Throwable) { + val recovered = try { + recoverFailedAppend(current, failClosedMetadata, key) + } catch (recoveryFailure: Throwable) { + failure.addSuppressed(recoveryFailure) + throw failure + } + throw StudyStoreMutationFailedClosed(recovered, failure) + } + } + + private fun recoverFailedAppend( + main: StudyMetadata, + failClosedMetadata: StudyMetadata, + key: SecretKey, + ): StudyMetadata { + val scan = scanEvents( + key = key, + fromSequenceInclusive = 1, + upToSequenceInclusive = Long.MAX_VALUE, + recoverTail = true, + decryptPayloads = false, + ) + val durableTail = if (failClosedMetadata.eventCount == scan.lastSequence) { + readDurableTail(key, scan.lastSequence) + } else { + null + } + val recovery = AppendTransactionRecovery.recover( + main = main, + transaction = failClosedMetadata, + durableLastSequence = scan.lastSequence, + durableTail = durableTail, + ) + if (recovery.rewriteMetadata) writeMetadataDocument(recovery.metadata, key) + commitAuthoritativeMetadata(recovery.metadata) + // Keep the fail-closed journal as provenance until the runtime supplies the winning + // application safety reason. This is what makes first-wins survive process death. + appendRecoveryRequired = true + return recovery.metadata + } + + private fun retireTransactionAfterCommit() { + try { + transactionFile.delete() + } catch (_: Exception) { + // Main metadata is already authoritative. A surviving journal makes the next open + // conservatively recover PAUSED; an absent journal leaves the acknowledged commit. + } } override suspend fun readEvents( @@ -204,6 +357,7 @@ class EncryptedExperimentStore( targetBytes: Long, ): StudyMetadata = withContext(Dispatchers.IO) { mutex.withLock { + requireNoPendingAppendRecovery() requireNotNull(persistedMetadata) { "Study storage is not initialized" } require(metadata.experimentId == experimentId) { "Experiment ID mismatch" } // The caller's copy is written back with a new floor, so it must not be stale in any @@ -230,11 +384,23 @@ class EncryptedExperimentStore( // indistinguishable from a prefix having been tampered away. val updated = metadata.copy(retainedFromSequence = plan.retainedFromSequence) persistMetadata(updated, key) - plan.segmentIndices.forEach { index -> + // Unlinking is physical cleanup after the logical floor commit. Stop at the first + // failed unlink so the remaining files stay one contiguous suffix; returning the + // authoritative metadata is safe because every planned segment was already confirmed + // by the endpoint. A later reclaim or process recovery can retry the harmless prefix. + for (index in plan.segmentIndices) { val file = eventDirectory.resolve("events-${index.toString().padStart(8, '0')}.ptcs") - check(!file.exists() || file.delete()) { "Cannot delete event segment" } + try { + if (file.exists()) { + if (!deleteSegment(file) || file.exists()) break + } + fileSystem.syncDirectory(eventDirectory) + } catch (_: Exception) { + // The logical floor and authoritative metadata were acknowledged first. A + // surviving delivered prefix is harmless and will be retried on a later pass. + break + } } - persistedRetainedFrom = plan.retainedFromSequence updated } } @@ -243,22 +409,53 @@ class EncryptedExperimentStore( mutex.withLock { metadataFile.delete() transactionFile.delete() - eventDirectory.listFiles()?.forEach { file -> check(file.delete()) { "Cannot delete event segment" } } - if (eventDirectory.exists()) check(eventDirectory.delete()) { "Cannot delete event directory" } + eventDirectoryEntries().forEach { file -> + fileSystem.deleteIfExists(file) + check(!fileSystem.exists(file)) { "Cannot delete event segment" } + } + if (eventDirectory.exists()) { + fileSystem.syncDirectory(eventDirectory) + fileSystem.deleteIfExists(eventDirectory) + check(!fileSystem.exists(eventDirectory)) { "Cannot delete event directory" } + fileSystem.syncDirectory(rootDirectory) + } if (keyStore.containsAlias(keyAlias)) keyStore.deleteEntry(keyAlias) persistedSequenceBoundary = 0 persistedRetainedFrom = 1 persistedMetadata = null + appendRecoveryRequired = false + } + } + + private fun requireNoPendingAppendRecovery() { + check(!appendRecoveryRequired) { + "Append transaction requires fail-closed recovery before another mutation" } } + private fun existingKeyOrThrow(): SecretKey = + existingKey() ?: error("Encrypted experiment key is unavailable") + private fun persistMetadata( metadata: StudyMetadata, key: SecretKey, + ) { + writeMetadataDocument(metadata, key) + commitAuthoritativeMetadata(metadata) + } + + private fun writeMetadataDocument( + metadata: StudyMetadata, + key: SecretKey, ) { val encoded = StudyDataJsonCodec.encodeMetadata(metadata) require(encoded.size <= MAXIMUM_METADATA_BYTES) { "Experiment metadata quota exceeded" } writeMetadata(encryptDocument(encoded, key, METADATA_HEADER)) + } + + private fun commitAuthoritativeMetadata(metadata: StudyMetadata) { + persistedSequenceBoundary = metadata.eventCount + persistedRetainedFrom = metadata.retainedFromSequence persistedMetadata = metadata } @@ -273,19 +470,18 @@ class EncryptedExperimentStore( require(storageBytes() + frameBytes <= maximumLocalBytes - METADATA_RESERVE_BYTES) { "Experiment event quota exceeded" } - require(eventDirectory.exists() || eventDirectory.mkdirs()) { "Cannot create event directory" } + fileSystem.ensureDirectory(eventDirectory) var segment = latestSegment() ?: createSegment(1) if (segment.file.length() + frameBytes > MAXIMUM_SEGMENT_BYTES) { segment = createSegment(segment.index + 1) } - RandomAccessFile(segment.file, "rw").use { output -> - output.seek(output.length()) - output.writeLong(event.sequenceNumber) - output.writeInt(encrypted.ciphertext.size) - output.write(encrypted.iv) - output.write(encrypted.ciphertext) - output.fd.sync() - } + val frame = ByteBuffer.allocate(frameBytes) + .putLong(event.sequenceNumber) + .putInt(encrypted.ciphertext.size) + .put(encrypted.iv) + .put(encrypted.ciphertext) + .array() + appendFrame(segment.file, frame) } /** @@ -305,12 +501,7 @@ class EncryptedExperimentStore( decryptPayloads: Boolean = true, consume: (RecordedEvent) -> Unit = {}, ): EventScan { - val segments = eventDirectory.listFiles() - .orEmpty() - .mapNotNull { file -> SEGMENT_PATTERN.matchEntire(file.name)?.groupValues?.get(1)?.toInt()?.let { - Segment(it, file) - } } - .sortedBy(Segment::index) + val segments = segments() // Survivors must be contiguous among themselves. They no longer have to start at index 1, // because reclaiming removes whole leading segments and never reuses an index. val baseIndex = segments.firstOrNull()?.index ?: 1 @@ -410,22 +601,40 @@ class EncryptedExperimentStore( require(index in 1..MAXIMUM_SEGMENT_INDEX) { "Event segment index exhausted" } require(segments().size < MAXIMUM_LIVE_SEGMENTS) { "Too many event segments" } val file = eventDirectory.resolve("events-${index.toString().padStart(8, '0')}.ptcs") - require(file.createNewFile()) { "Cannot create event segment" } - RandomAccessFile(file, "rw").use { output -> - output.write(SEGMENT_HEADER) - output.writeInt(index) - output.fd.sync() - } + require(!file.exists()) { "Event segment already exists" } + val header = ByteBuffer.allocate(SEGMENT_HEADER_BYTES) + .put(SEGMENT_HEADER) + .putInt(index) + .array() + AcknowledgedAtomicFile(file, fileSystem).write(header) return Segment(index, file) } - private fun segments(): List = eventDirectory.listFiles() - .orEmpty() - .mapNotNull { file -> SEGMENT_PATTERN.matchEntire(file.name)?.groupValues?.get(1)?.toInt()?.let { - Segment(it, file) - } } + private fun segments(): List = eventDirectoryEntries() + .map { file -> + val incomplete = SEGMENT_PENDING_PATTERN.matchEntire(file.name) + ?: SEGMENT_REPLACEMENT_PATTERN.matchEntire(file.name) + incomplete?.let { match -> + throw IncompleteAtomicWrite( + eventDirectory.resolve("events-${match.groupValues[1]}.ptcs"), + ) + } + val match = requireNotNull(SEGMENT_PATTERN.matchEntire(file.name)) { + "Unexpected entry in event storage: ${file.name}" + } + require(file.isFile) { "Event segment is not a regular file" } + Segment(match.groupValues[1].toInt(), file) + } .sortedBy(Segment::index) + private fun eventDirectoryEntries(): List { + if (!fileSystem.exists(eventDirectory)) return emptyList() + check(fileSystem.isDirectory(eventDirectory)) { "Event storage is not a directory" } + return checkNotNull(fileSystem.listFiles(eventDirectory)) { + "Cannot enumerate event storage" + }.toList() + } + private fun latestSegment(): Segment? = segments().maxByOrNull(Segment::index) /** @@ -440,7 +649,7 @@ class EncryptedExperimentStore( } private fun storageBytes(): Long = metadataFile.baseFile.length() + - eventDirectory.listFiles().orEmpty().sumOf(File::length) + eventDirectoryEntries().sumOf(File::length) private fun encryptDocument( plaintext: ByteArray, @@ -513,19 +722,7 @@ class EncryptedExperimentStore( .putLong(sequenceNumber) .array() - private fun writeMetadata(encrypted: ByteArray) = writeAtomic(metadataFile, encrypted) - - private fun writeAtomic(file: AtomicFile, encrypted: ByteArray) { - require(rootDirectory.exists() || rootDirectory.mkdirs()) { "Cannot create experiment storage directory" } - val output = file.startWrite() - try { - output.write(encrypted) - file.finishWrite(output) - } catch (failure: Throwable) { - file.failWrite(output) - throw failure - } - } + private fun writeMetadata(encrypted: ByteArray) = metadataFile.write(encrypted) private fun existingKey(): SecretKey? = keyStore.getKey(keyAlias, null) as? SecretKey @@ -594,8 +791,28 @@ class EncryptedExperimentStore( // exact string is refused rather than migrated. val METADATA_HEADER = "PTCMET01".toByteArray(Charsets.US_ASCII) val TRANSACTION_HEADER = "PTCTXN01".toByteArray(Charsets.US_ASCII) + val SAFETY_PAUSE_TRANSITION_REASONS = setOf( + TransitionReason.REQUIRED_ACCESS_MISSING, + TransitionReason.COLLECTION_HOST_FAILURE, + TransitionReason.WORK_SCHEDULING_FAILURE, + TransitionReason.COLLECTION_TEARDOWN_FAILURE, + TransitionReason.STORAGE_FAILURE, + ) + + fun appendFrameDurably( + file: File, + frame: ByteArray, + ) { + RandomAccessFile(file, "rw").use { output -> + output.seek(output.length()) + output.write(frame) + output.fd.sync() + } + } val SEGMENT_HEADER = "PTCEVT01".toByteArray(Charsets.US_ASCII) val SEGMENT_PATTERN = Regex("events-([0-9]{8})\\.ptcs") + val SEGMENT_PENDING_PATTERN = Regex("\\.events-([0-9]{8})\\.ptcs\\.pending") + val SEGMENT_REPLACEMENT_PATTERN = Regex("\\.events-([0-9]{8})\\.ptcs\\.replacement") val SEGMENT_HEADER_BYTES = SEGMENT_HEADER.size + Int.SIZE_BYTES val MINIMUM_METADATA_FILE_BYTES = METADATA_HEADER.size + IV_BYTES + GCM_TAG_BYTES + 2 val MAXIMUM_METADATA_FILE_BYTES = METADATA_HEADER.size + IV_BYTES + GCM_TAG_BYTES + MAXIMUM_METADATA_BYTES diff --git a/core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/AcknowledgedAtomicFileTest.kt b/core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/AcknowledgedAtomicFileTest.kt new file mode 100644 index 0000000..6803103 --- /dev/null +++ b/core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/AcknowledgedAtomicFileTest.kt @@ -0,0 +1,295 @@ +package cool.jacoblin.particeps.core.storage + +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class AcknowledgedAtomicFileTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun successfulWritePerformsTheCompleteAcknowledgementSequence() { + val operations = RecordingFileSystem() + val target = temporaryFolder.root.resolve("record.ptc") + val file = AcknowledgedAtomicFile(target, operations) + + file.write("durable".toByteArray()) + + assertArrayEquals("durable".toByteArray(), target.readBytes()) + assertTrue( + operations.calls.containsSubsequence( + "open:.record.ptc.pending", + "sync-file", + "close-file", + "open:.record.ptc.replacement", + "sync-file", + "close-file", + "sync-directory:${temporaryFolder.root.name}", + "rename:.record.ptc.replacement->record.ptc", + "read:record.ptc", + "sync-directory:${temporaryFolder.root.name}", + "delete:.record.ptc.pending", + "sync-directory:${temporaryFolder.root.name}", + ), + ) + } + + @Test + fun fileSyncFailureCannotReplaceOrAcknowledgeTheOldValue() { + assertWriteFailsBeforeReplace(FailurePoint.FILE_SYNC) + } + + @Test + fun checkedCloseFailureCannotReplaceOrAcknowledgeTheOldValue() { + assertWriteFailsBeforeReplace(FailurePoint.FILE_CLOSE) + } + + @Test + fun atomicRenameFailureCannotReplaceOrAcknowledgeTheOldValue() { + assertWriteFailsBeforeReplace(FailurePoint.ATOMIC_REPLACE) + } + + @Test + fun replacementDirectorySyncFailureRetainsAnIndependentUncertaintyWitness() { + val target = temporaryFolder.root.resolve("record.ptc").apply { writeText("old") } + val operations = RecordingFileSystem(FailurePoint.REPLACEMENT_DIRECTORY_SYNC) + val file = AcknowledgedAtomicFile(target, operations) + + assertThrows(IOException::class.java) { + file.write("new".toByteArray()) + } + + // rename(2) is visible, but the independent witness survives the failed parent fsync so a + // new process cannot mistake an unacknowledged RUNNING transition for a committed one. + assertArrayEquals("new".toByteArray(), target.readBytes()) + assertThrows(IncompleteAtomicWrite::class.java) { + AcknowledgedAtomicFile(target, RecordingFileSystem()).readFully() + } + } + + @Test + fun witnessCleanupFailureDoesNotRollBackAnAcknowledgedReplacement() { + val target = temporaryFolder.root.resolve("record.ptc").apply { writeText("old") } + val operations = RecordingFileSystem(FailurePoint.CLEANUP_DIRECTORY_SYNC) + + AcknowledgedAtomicFile(target, operations).write("new".toByteArray()) + + assertArrayEquals("new".toByteArray(), target.readBytes()) + assertArrayEquals( + "new".toByteArray(), + AcknowledgedAtomicFile(target, RecordingFileSystem()).readFully(), + ) + } + + @Test + fun readbackIdentityMismatchCannotBeReportedAsSuccess() { + val target = temporaryFolder.root.resolve("record.ptc").apply { writeText("old") } + val operations = RecordingFileSystem(FailurePoint.READBACK_MISMATCH) + + assertThrows(IllegalStateException::class.java) { + AcknowledgedAtomicFile(target, operations).write("new".toByteArray()) + } + + assertTrue(operations.calls.any { it.startsWith("sync-directory:") }) + assertThrows(IncompleteAtomicWrite::class.java) { + AcknowledgedAtomicFile(target, RecordingFileSystem()).readFully() + } + } + + @Test + fun deleteFailureCannotBeReportedAsSuccess() { + val target = temporaryFolder.root.resolve("record.ptc").apply { writeText("value") } + val operations = RecordingFileSystem(FailurePoint.DELETE) + val file = AcknowledgedAtomicFile(target, operations) + + assertThrows(IOException::class.java) { file.delete() } + + assertTrue(target.exists()) + } + + @Test + fun deleteRemovesBaseAndStagedDataBeforeSyncingTheDirectory() { + val target = temporaryFolder.root.resolve("record.ptc").apply { writeText("value") } + val staged = temporaryFolder.root.resolve(".record.ptc.pending").apply { writeText("staged") } + val operations = RecordingFileSystem() + val file = AcknowledgedAtomicFile(target, operations) + + file.delete() + + assertFalse(target.exists()) + assertFalse(staged.exists()) + assertTrue(operations.calls.last().startsWith("sync-directory:")) + } + + @Test + fun explicitRetryCheckedDeletesAndSyncsAnIncompleteWriteBeforeStartingAgain() { + val target = temporaryFolder.root.resolve("record.ptc").apply { writeText("old") } + val failedOperations = RecordingFileSystem(FailurePoint.ATOMIC_REPLACE) + assertThrows(IOException::class.java) { + AcknowledgedAtomicFile(target, failedOperations).write("interrupted".toByteArray()) + } + val retryOperations = RecordingFileSystem() + + AcknowledgedAtomicFile(target, retryOperations).write("retry".toByteArray()) + + assertArrayEquals("retry".toByteArray(), target.readBytes()) + assertTrue( + retryOperations.calls.containsSubsequence( + "delete:.record.ptc.pending", + "sync-directory:${temporaryFolder.root.name}", + "open:.record.ptc.pending", + ), + ) + } + + @Test + fun frameworkAtomicFileResidueBlocksRecoveryWithoutGuessing() { + listOf("new", "bak").forEach { suffix -> + val target = temporaryFolder.root.resolve("legacy-$suffix.ptc").apply { writeText("old") } + temporaryFolder.root.resolve("${target.name}.$suffix").writeText("unresolved") + val file = AcknowledgedAtomicFile(target, RecordingFileSystem()) + + assertTrue(file.exists()) + assertThrows(IncompleteAtomicWrite::class.java) { file.readFully() } + assertArrayEquals("old".toByteArray(), target.readBytes()) + } + } + + @Test + fun explicitReplacementRetiresEveryKnownStagedLayoutBeforeWritingKnownBytes() { + val target = temporaryFolder.root.resolve("legacy.ptc").apply { writeText("old") } + temporaryFolder.root.resolve("legacy.ptc.new").writeText("new-residue") + temporaryFolder.root.resolve("legacy.ptc.bak").writeText("backup-residue") + val operations = RecordingFileSystem() + + AcknowledgedAtomicFile(target, operations).write("replacement".toByteArray()) + + assertArrayEquals("replacement".toByteArray(), target.readBytes()) + assertTrue( + operations.calls.containsSubsequence( + "delete:legacy.ptc.new", + "delete:legacy.ptc.bak", + "sync-directory:${temporaryFolder.root.name}", + "open:.legacy.ptc.pending", + ), + ) + } + + private fun assertWriteFailsBeforeReplace(failurePoint: FailurePoint) { + val target = temporaryFolder.root.resolve("record-${failurePoint.name}.ptc").apply { + writeText("old") + } + val operations = RecordingFileSystem(failurePoint) + val file = AcknowledgedAtomicFile(target, operations) + + assertThrows(IOException::class.java) { + file.write("new".toByteArray()) + } + + assertArrayEquals("old".toByteArray(), target.readBytes()) + val reopened = AcknowledgedAtomicFile(target, operations) + assertTrue(reopened.exists()) + assertThrows(IncompleteAtomicWrite::class.java) { reopened.readFully() } + } + + private enum class FailurePoint { + FILE_SYNC, + FILE_CLOSE, + ATOMIC_REPLACE, + READBACK_MISMATCH, + REPLACEMENT_DIRECTORY_SYNC, + CLEANUP_DIRECTORY_SYNC, + DELETE, + } + + private class RecordingFileSystem( + private val failurePoint: FailurePoint? = null, + ) : AcknowledgedFileSystem { + val calls = mutableListOf() + + override fun exists(file: File): Boolean = file.exists() + + override fun isDirectory(file: File): Boolean = file.isDirectory + + override fun listFiles(directory: File): Array? = directory.listFiles() + + override fun ensureDirectory(directory: File) { + check(directory.isDirectory) + } + + override fun openOutput(file: File): FileOutputStream { + calls += "open:${file.name}" + return FileOutputStream(file, false) + } + + override fun syncFile(output: FileOutputStream) { + calls += "sync-file" + output.fd.sync() + if (failurePoint == FailurePoint.FILE_SYNC) throw IOException("injected sync failure") + } + + override fun closeFile(output: FileOutputStream) { + calls += "close-file" + output.close() + if (failurePoint == FailurePoint.FILE_CLOSE) throw IOException("injected close failure") + } + + override fun atomicReplace(source: File, target: File) { + calls += "rename:${source.name}->${target.name}" + if (failurePoint == FailurePoint.ATOMIC_REPLACE) throw IOException("injected rename failure") + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } + + override fun readFully(file: File): ByteArray { + calls += "read:${file.name}" + val bytes = file.readBytes() + return if (failurePoint == FailurePoint.READBACK_MISMATCH) { + bytes.copyOf().also { it[0] = (it[0].toInt() xor 0x01).toByte() } + } else { + bytes + } + } + + override fun deleteIfExists(file: File) { + calls += "delete:${file.name}" + if (failurePoint == FailurePoint.DELETE && !file.name.startsWith(".")) { + throw IOException("injected delete failure") + } + Files.deleteIfExists(file.toPath()) + } + + override fun syncDirectory(directory: File) { + calls += "sync-directory:${directory.name}" + val syncAttempt = calls.count { it.startsWith("sync-directory:") } + if ( + (failurePoint == FailurePoint.REPLACEMENT_DIRECTORY_SYNC && syncAttempt == 2) || + (failurePoint == FailurePoint.CLEANUP_DIRECTORY_SYNC && syncAttempt == 3) + ) { + throw IOException("injected directory sync failure") + } + } + } +} + +private fun List.containsSubsequence(vararg expected: String): Boolean { + var cursor = 0 + forEach { actual -> + if (cursor < expected.size && actual == expected[cursor]) cursor += 1 + } + return cursor == expected.size +} diff --git a/core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/AppendTransactionRecoveryTest.kt b/core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/AppendTransactionRecoveryTest.kt index ea1d3ec..a772a3a 100644 --- a/core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/AppendTransactionRecoveryTest.kt +++ b/core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/AppendTransactionRecoveryTest.kt @@ -1,10 +1,13 @@ package cool.jacoblin.particeps.core.storage import cool.jacoblin.particeps.core.model.InterventionOccurrence +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.ExperimentStateMachine import cool.jacoblin.particeps.core.model.OccurrenceState import cool.jacoblin.particeps.core.model.RecordedEvent import cool.jacoblin.particeps.core.model.ResearchTime import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.TransitionReason import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertThrows @@ -17,9 +20,10 @@ class AppendTransactionRecoveryTest { val main = initial() val event = event(1, "battery_state.v1") val occurrence = occurrence() - val transaction = main.withEvent(event).copy( + val successor = main.withEvent(event).copy( occurrences = mapOf(occurrence.occurrenceId to occurrence), ) + val transaction = successor.failClosed() val result = AppendTransactionRecovery.recover(main, transaction, 1, event) @@ -28,28 +32,46 @@ class AppendTransactionRecoveryTest { } @Test - fun crashAfterJournalWriteDiscardsThePreparedTransaction() { + fun crashAfterJournalWriteFailsClosedWithoutInventingAnEvent() { val main = initial() - val transaction = main.withEvent(event(1, "battery_state.v1")) + val transaction = main.withEvent(event(1, "battery_state.v1")).failClosed() val result = AppendTransactionRecovery.recover(main, transaction, 0, null) - assertEquals(main, result.metadata) - assertFalse(result.rewriteMetadata) + assertEquals(ExperimentState.PAUSED, result.metadata.state) + assertEquals(TransitionReason.STORAGE_FAILURE, result.metadata.transitions.last().reason) + assertEquals(0, result.metadata.eventCount) + assertTrue(result.rewriteMetadata) } @Test - fun crashAfterMainCommitKeepsMainAndDiscardsTheLeftoverJournal() { + fun crashAfterMainCommitButBeforeJournalRetirementStillFailsClosed() { val committed = initial().withEvent(event(1, "battery_state.v1")) - val staleJournal = committed.copy(occurrences = emptyMap()) + val transaction = committed.failClosed() + + val result = AppendTransactionRecovery.recover( + committed, + transaction, + committed.eventCount, + committed.lastEvents.values.single(), + ) + + assertEquals(transaction, result.metadata) + assertTrue(result.rewriteMetadata) + } + + @Test + fun laterDurableMetadataProvesAnUnretiredJournalIsOnlyCleanupResidue() { + val committed = initial().withEvent(event(1, "battery_state.v1")) + val transaction = committed.failClosed() val occurrence = occurrence() val newerMain = committed.copy(occurrences = mapOf(occurrence.occurrenceId to occurrence)) val result = AppendTransactionRecovery.recover( newerMain, - staleJournal, + transaction, newerMain.eventCount, - null, + committed.lastEvents.values.single(), ) assertEquals(newerMain, result.metadata) @@ -59,7 +81,7 @@ class AppendTransactionRecoveryTest { @Test fun sameBoundaryJournalMustStillBelongToTheSameStudyIdentity() { val main = initial().withEvent(event(1, "battery_state.v1")) - val wrongConfiguration = main.copy(configurationId = "another-config") + val wrongConfiguration = main.failClosed().copy(configurationId = "another-config") assertThrows(IllegalArgumentException::class.java) { AppendTransactionRecovery.recover(main, wrongConfiguration, main.eventCount, null) @@ -81,18 +103,26 @@ class AppendTransactionRecoveryTest { eventCount = 2, nextSequenceNumber = 3, lastEvents = main.lastEvents + ("temporal_context.v1" to appended.copy(sequenceNumber = 1)), - ) + ).failClosed() assertThrows(IllegalArgumentException::class.java) { AppendTransactionRecovery.recover(main, staleTransaction, 2, appended) } val wrongTail = appended.copy(payloadType = "OTHER") assertThrows(IllegalArgumentException::class.java) { - AppendTransactionRecovery.recover(main, main.withEvent(appended), 2, wrongTail) + AppendTransactionRecovery.recover(main, main.withEvent(appended).failClosed(), 2, wrongTail) } } private fun initial() = StudyMetadata.initial("recovery-test", "recovery-config") + .copy(state = ExperimentState.RUNNING) + + private fun StudyMetadata.failClosed() = ExperimentStateMachine().transition( + this, + ExperimentState.PAUSED, + TransitionReason.STORAGE_FAILURE, + ResearchTime(10_000, 10_000, "boot-test"), + ) private fun StudyMetadata.withEvent(event: RecordedEvent) = copy( eventCount = event.sequenceNumber, diff --git a/core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/StudyDataReconciliationTest.kt b/core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/StudyDataReconciliationTest.kt index d11fa89..c6d3a66 100644 --- a/core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/StudyDataReconciliationTest.kt +++ b/core/storage/src/test/kotlin/cool/jacoblin/particeps/core/storage/StudyDataReconciliationTest.kt @@ -1,8 +1,11 @@ package cool.jacoblin.particeps.core.storage import cool.jacoblin.particeps.core.model.RecordedEvent +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.ExperimentStateMachine import cool.jacoblin.particeps.core.model.ResearchTime import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.TransitionReason import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue @@ -14,15 +17,25 @@ class StudyDataReconciliationTest { val first = event(1) val stored = StudyMetadata.initial("reconcile-test", "reconcile-config") .withEvent(first) - .copy(uploadedThroughSequence = 1, retainedFromSequence = 2) + .copy( + state = ExperimentState.RUNNING, + uploadedThroughSequence = 1, + retainedFromSequence = 2, + ) val reconciled = StudyDataJsonCodec.reconcileMetadata(stored, 1, 1) assertEquals(1L, reconciled.retainedFromSequence) val second = event(2) val successor = reconciled.withEvent(second) - val recovered = AppendTransactionRecovery.recover(reconciled, successor, 2, second) - assertEquals(successor, recovered.metadata) + val failClosed = ExperimentStateMachine().transition( + successor, + ExperimentState.PAUSED, + TransitionReason.STORAGE_FAILURE, + ResearchTime(10_000, 10_000, "boot-test"), + ) + val recovered = AppendTransactionRecovery.recover(reconciled, failClosed, 2, second) + assertEquals(failClosed, recovered.metadata) assertTrue(recovered.rewriteMetadata) } diff --git a/core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/InterventionSchedulePlanner.kt b/core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/InterventionSchedulePlanner.kt index 68d8fcc..04e960e 100644 --- a/core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/InterventionSchedulePlanner.kt +++ b/core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/InterventionSchedulePlanner.kt @@ -36,6 +36,12 @@ class InterventionSchedulePlanner( if (metadata.state != ExperimentState.RUNNING) return emptyList() val firstStart = metadata.transitions.firstOrNull { it.reason == TransitionReason.PARTICIPANT_STARTED }?.time ?: return emptyList() + require(now.bootSessionId == firstStart.bootSessionId) { + "Cannot plan active study work across an untrusted boot-time boundary" + } + require(now.elapsedRealtimeNanos >= firstStart.elapsedRealtimeNanos) { + "Active study monotonic clock moved behind participant Start" + } val effectiveStartWallUtcMillis = now.wallTimeUtcMillis - elapsedMillis(firstStart, now) val lifetimeEnd = effectiveStartWallUtcMillis + configuration.durationHours * HOUR_MILLIS @@ -249,14 +255,9 @@ class InterventionSchedulePlanner( return ResearchTime(wallMillis, reference.elapsedRealtimeNanos + deltaNanos, reference.bootSessionId) } - /** Uses the monotonic clock whenever both endpoints belong to one boot, so wall-clock edits do - * not turn paused time into active study time. Across boots wall time is the only shared base. */ + /** Both endpoints are proven to belong to one boot before this monotonic subtraction. */ private fun elapsedMillis(start: ResearchTime, end: ResearchTime): Long = - if (start.bootSessionId == end.bootSessionId && end.elapsedRealtimeNanos >= start.elapsedRealtimeNanos) { - (end.elapsedRealtimeNanos - start.elapsedRealtimeNanos) / 1_000_000 - } else { - (end.wallTimeUtcMillis - start.wallTimeUtcMillis).coerceAtLeast(0) - } + (end.elapsedRealtimeNanos - start.elapsedRealtimeNanos) / 1_000_000 private fun occurrenceId( configuration: StudyConfiguration, diff --git a/core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyApplication.kt b/core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyApplication.kt index cc404ab..89be739 100644 --- a/core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyApplication.kt +++ b/core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyApplication.kt @@ -1,17 +1,24 @@ package cool.jacoblin.particeps.core.application import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessInspectionRequest import cool.jacoblin.particeps.core.collector.AccessRequirement -import cool.jacoblin.particeps.core.collector.AccessStatus +import cool.jacoblin.particeps.core.collector.AccessResolution +import cool.jacoblin.particeps.core.collector.CollectorAccessRequirement import cool.jacoblin.particeps.core.collector.CollectorRegistry +import cool.jacoblin.particeps.core.collector.LocationAccessProfile +import cool.jacoblin.particeps.core.collector.NotificationAccessFeature +import cool.jacoblin.particeps.core.collector.SetupGuidance import cool.jacoblin.particeps.core.collector.StudyAccessGateway import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.definition.LocationConfiguration import cool.jacoblin.particeps.core.definition.SurveyAction import cool.jacoblin.particeps.core.export.ExportReceipt import cool.jacoblin.particeps.core.model.ExperimentState import cool.jacoblin.particeps.core.model.InterventionOccurrence import cool.jacoblin.particeps.core.model.OccurrenceState import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.SafetyPauseReason import cool.jacoblin.particeps.core.model.StudyMetadata import cool.jacoblin.particeps.core.model.StudyStore import cool.jacoblin.particeps.core.protocol.ActiveStudyStore @@ -24,12 +31,17 @@ import cool.jacoblin.particeps.core.runtime.OccurrenceClaimResult import cool.jacoblin.particeps.core.runtime.OccurrenceDispatch import cool.jacoblin.particeps.core.runtime.OccurrenceExpiryResult import cool.jacoblin.particeps.core.runtime.RuntimeSnapshot +import cool.jacoblin.particeps.core.runtime.SafetyPauseWitness import cool.jacoblin.particeps.core.runtime.SurveyAnswer import cool.jacoblin.particeps.core.runtime.SurveySubmissionResult import java.io.OutputStream import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -38,6 +50,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext fun interface StudyVerifier { fun verify(envelopeBytes: ByteArray): VerifiedConfiguration } @@ -46,44 +59,69 @@ fun interface StudyStoreFactory { } fun interface ExperimentRuntimeFactory { - fun create(configuration: StudyConfiguration, store: StudyStore, availableAccess: () -> Set): ExperimentRuntime + fun create( + configuration: StudyConfiguration, + store: StudyStore, + safetyPauseWitness: SafetyPauseWitness, + ): ExperimentRuntime } interface StudyCollectionHost { - fun start(studyTitle: String, usesLocation: Boolean) + /** Returns only after the platform has acknowledged the requested foreground-service type. */ + suspend fun start(studyTitle: String, usesLocation: Boolean) fun stop() } +/** App-private typed marker for any fail-closed pause that must survive process death. */ +interface SafetyPauseStore { + suspend fun pendingReason(): SafetyPauseReason? + suspend fun markPending(reason: SafetyPauseReason) + suspend fun clear() +} + interface StudyWorkScheduler { - fun schedule(configuration: StudyConfiguration) + /** Idempotently persists all work implied by the durable study state. */ + suspend fun ensureCollectionWork( + configuration: StudyConfiguration, + metadata: StudyMetadata, + observedAt: ResearchTime, + ) - /** Rebuilds delivery and durable expiry work after state, clock, reboot, or time-zone recovery. */ - fun replaceInterventionWork( + /** Rebuilds delivery and expiry work after state or same-boot clock/time-zone reconciliation. */ + suspend fun replaceInterventionWork( configuration: StudyConfiguration, deliveries: List, expiries: List, ) /** Adds the successor of a completed trigger without disturbing unrelated work. */ - fun enqueueOccurrence(configuration: StudyConfiguration, occurrence: InterventionOccurrence) + suspend fun enqueueOccurrence(configuration: StudyConfiguration, occurrence: InterventionOccurrence) /** Cancels delivery/expiry work and visible notifications while a study is paused. */ - fun cancelInterventionWork(experimentId: String, occurrenceIds: Set) + suspend fun cancelInterventionWork(experimentId: String, occurrenceIds: Set) /** Idempotently removes notifications that durable occurrence state proves are no longer visible. */ fun cancelInterventionNotifications(occurrenceIds: Set) + /** Schedules an independent retry when the typed safety pause is not fully durable and clean. */ + suspend fun scheduleSafetyPauseRetry(experimentId: String, reason: SafetyPauseReason) + + /** Reads an active typed retry before recovery is allowed to start any host or collector. */ + suspend fun pendingSafetyPauseReason(experimentId: String): SafetyPauseReason? + + suspend fun cancelSafetyPauseRetry() + /** - * Cancels reminders and the study deadline, leaving scheduled delivery in place. + * Cancels interventions, reminders and the study deadline, leaving the upload tail in place. * * Used when a study ends. Collection is over, but events already recorded and not yet delivered * are still owed to the researcher — stranding them here would defeat the point of uploading at * all, since the participant may never perform a manual export. */ - fun cancelCollectionWork(experimentId: String, occurrenceIds: Set) + suspend fun cancelCollectionWork(experimentId: String, occurrenceIds: Set) /** Cancels everything, including undelivered work. Used when the data itself is going away. */ - fun cancel(experimentId: String) + suspend fun cancel(experimentId: String) } fun interface StudyExporter { @@ -163,11 +201,68 @@ sealed interface UploadAttemptResult { data class Failed(val reasonCode: String, val retryable: Boolean) : UploadAttemptResult } +/** Result of rechecking the immutable participant-start deadline before terminal completion. */ +sealed interface DurationCompletionResult { + data object Completed : DurationCompletionResult + + data class NotDue(val remainingMillis: Long) : DurationCompletionResult { + init { + require(remainingMillis > 0) { "An early deadline must retain a positive delay" } + } + } + + data object Inactive : DurationCompletionResult + data class Failed(val commandResult: CommandResult.Failed) : DurationCompletionResult +} + +enum class StudyAccessFeature { + STUDY_NOTIFICATIONS, +} + +sealed interface StudyAccessOwner { + val required: Boolean + + data class Collector( + val collectorId: String, + override val required: Boolean, + ) : StudyAccessOwner { + init { + require(collectorId.isNotBlank()) { "Access owner collector ID must not be blank" } + } + } + + data class Feature( + val feature: StudyAccessFeature, + override val required: Boolean, + ) : StudyAccessOwner +} + +data class StudyAccessPlanItem( + val requirement: AccessRequirement, + val owners: Set, +) { + init { + require(owners.isNotEmpty()) { "Planned access must have at least one owner" } + require(requirement.required == owners.any(StudyAccessOwner::required)) { + "Planned access requiredness must match its owners" + } + } +} + +data class StudyAccessStatus( + val requirement: AccessRequirement, + val owners: Set, + val resolution: AccessResolution, + val guidance: SetupGuidance?, +) { + val granted: Boolean get() = resolution == AccessResolution.Satisfied +} + data class StudySessionSnapshot( val initialized: Boolean = false, val configuration: StudyConfiguration? = null, val runtime: RuntimeSnapshot = RuntimeSnapshot(), - val access: List = emptyList(), + val access: List = emptyList(), /** * False when the study's signer was not pinned by this build, so the app cannot vouch for who * published it. The consent screen says so rather than letting the researcher name stand alone. @@ -179,22 +274,44 @@ data class StudySessionSnapshot( val upload: UploadStatus? = null, /** A durable deletion tombstone exists; collection and upload must never resume. */ val deletionPending: Boolean = false, + /** A safety boundary is pending; unreadable marker state is closed and never activates a study. */ + val safetyPauseStatus: SafetyPauseStatus? = null, + /** Recovery failed closed; ordinary study actions cannot clear this process-lifetime latch. */ + val recoveryBlocked: Boolean = false, val incidentCode: String? = null, ) +sealed interface SafetyPauseStatus { + data class Pending(val reason: SafetyPauseReason) : SafetyPauseStatus + data object MarkerUnreadable : SafetyPauseStatus +} + class StudyAccessPolicy { - fun requirements(configuration: StudyConfiguration, collectorRequirements: Set): Set { - val interventionRequirements = if (configuration.interventions.isEmpty()) { - emptySet() - } else { - setOf(AccessRequirement(AccessKind.NOTIFICATIONS, required = true)) - } - return (collectorRequirements + interventionRequirements) - .groupBy(AccessRequirement::kind) - .mapTo(mutableSetOf()) { (kind, entries) -> - AccessRequirement(kind, entries.any(AccessRequirement::required)) + fun plan(collectorRequirements: List): List { + val ownedRequirements = collectorRequirements.map { entry -> + OwnedAccessRequirement( + requirement = entry.requirement, + owner = StudyAccessOwner.Collector(entry.collectorId, entry.requirement.required), + ) + } + OwnedAccessRequirement( + requirement = AccessRequirement(AccessKind.NOTIFICATIONS, required = true), + owner = StudyAccessOwner.Feature(StudyAccessFeature.STUDY_NOTIFICATIONS, required = true), + ) + return ownedRequirements + .groupBy { it.requirement.kind } + .map { (kind, entries) -> + val owners = entries.mapTo(mutableSetOf(), OwnedAccessRequirement::owner) + StudyAccessPlanItem( + requirement = AccessRequirement(kind, owners.any(StudyAccessOwner::required)), + owners = owners, + ) } } + + private data class OwnedAccessRequirement( + val requirement: AccessRequirement, + val owner: StudyAccessOwner, + ) } class StudySessionManager( @@ -205,6 +322,7 @@ class StudySessionManager( private val collectorRegistry: CollectorRegistry, private val accessGateway: StudyAccessGateway, private val collectionHost: StudyCollectionHost, + private val safetyPauseStore: SafetyPauseStore, private val workScheduler: StudyWorkScheduler, private val exporter: StudyExporter, private val uploader: StudyUploader, @@ -228,26 +346,48 @@ class StudySessionManager( private var verifiedConfiguration: VerifiedConfiguration? = null private var runtimeObservation: Job? = null private var deletionPending = false + private var safetyPauseStatus: SafetyPauseStatus? = null + private var recoveryBlocked = false + private var collectionHostStarted = false + private var collectionHostUsesLocation = false suspend fun initialize() = sessionMutex.withLock { check(!mutableSnapshot.value.initialized) { "Study session is already initialized" } try { + safetyPauseStatus = try { + safetyPauseStore.pendingReason()?.let(SafetyPauseStatus::Pending) + } catch (failure: Throwable) { + failure.rethrowCancellation() + // An unreadable marker is not evidence that the safety boundary completed. + safetyPauseStatus = SafetyPauseStatus.MarkerUnreadable + throw failure + } when (val saved = activeStudyStore.load()) { - null -> mutableSnapshot.value = StudySessionSnapshot(initialized = true) + null -> { + clearSafetyPauseLocked() + mutableSnapshot.value = StudySessionSnapshot(initialized = true) + } is ActiveStudyRecord.Active -> activate(saved.envelopeBytes, persistEnvelope = false, joinLink = null) is ActiveStudyRecord.DeletionPending -> { deletionPending = true - mutableSnapshot.value = StudySessionSnapshot(deletionPending = true) + mutableSnapshot.value = StudySessionSnapshot( + deletionPending = true, + safetyPauseStatus = safetyPauseStatus, + ) completePendingDeletion(saved) + clearSafetyPauseLocked() mutableSnapshot.value = StudySessionSnapshot(initialized = true) } } } catch (failure: Throwable) { failure.rethrowCancellation() + recoveryBlocked = true mutableSnapshot.update { it.copy( initialized = true, deletionPending = deletionPending, + safetyPauseStatus = safetyPauseStatus, + recoveryBlocked = true, incidentCode = INCIDENT_STUDY_RECOVERY_FAILED, ) } @@ -255,6 +395,9 @@ class StudySessionManager( } suspend fun importSignedConfiguration(bytes: ByteArray, joinLink: JoinLink? = null) = sessionMutex.withLock { + check(!recoveryBlocked) { + "Repair the blocked active-study recovery before importing another study" + } check(runtime == null && !deletionPending) { "Finish pending study deletion before importing another" } @@ -270,82 +413,239 @@ class StudySessionManager( suspend fun reviewStudy(): CommandResult = command(execute = { it.reviewStudy() }) suspend fun acceptConsent(): CommandResult = command( execute = { it.acceptConsent() }, - onSuccess = ::refreshAccess, + onSuccess = ::refreshAccessLocked, ) suspend fun completeAccessSetup(): CommandResult = sessionMutex.withLock { - refreshAccess() - if (mutableSnapshot.value.access.any { it.requirement.required && !it.granted }) { + refreshAccessLocked() + if (!requiredAccessReady()) { return@withLock publish(CommandResult.Failed(INCIDENT_REQUIRED_ACCESS_MISSING)) } publish(requireRuntime().completeAccessSetup(currentGrantedKinds())) } suspend fun start(): CommandResult = sessionMutex.withLock { - refreshAccess() val current = requireRuntime() + if (!retrySafetyPauseLocked(current.configuration.experimentId)) { + return@withLock CommandResult.Failed(INCIDENT_SAFETY_PAUSE_PENDING) + } + refreshAccessLocked() + if (!requiredAccessReady()) { + return@withLock publish(CommandResult.Failed(INCIDENT_REQUIRED_ACCESS_MISSING)) + } + val availableAccess = currentGrantedKinds() try { - collectionHost.start(current.configuration.title, usesLocation()) + ensureCollectionHostLocked(current.configuration.title, usesLocation(availableAccess)) } catch (failure: Throwable) { failure.rethrowCancellation() return@withLock publish(CommandResult.Failed(INCIDENT_COLLECTION_HOST_FAILED)) } - val result = current.start() + val result = try { + current.start(availableAccess) + } catch (failure: Throwable) { + return@withLock containCommandActivationFailureLocked( + current = current, + failure = failure, + defaultReason = SafetyPauseReason.COLLECTION_HOST_FAILURE, + defaultIncidentCode = INCIDENT_COLLECTION_HOST_FAILED, + ) + } if (result != CommandResult.Success) { - collectionHost.stop() + current.snapshot.value.pendingSafetyPauseReason?.let { + return@withLock containCommandActivationFailureLocked( + current = current, + failure = CommandActivationFailure(result), + defaultReason = it, + defaultIncidentCode = it.incidentCode(), + ) + } + withContext(NonCancellable) { + runAllCleanupSteps( + { stopCollectionHostLocked() }, + { + workScheduler.cancelInterventionWork( + current.configuration.experimentId, + occurrenceIds(current), + ) + }, + ) + } return@withLock publish(result) } try { - workScheduler.schedule(current.configuration) + ensureCollectionWorkLocked(current) syncInterventionsLocked(current) publish(result) } catch (failure: Throwable) { - failure.rethrowCancellation() - if (current.pause() != CommandResult.Success) current.shutdown() - workScheduler.cancel(current.configuration.experimentId) - collectionHost.stop() - publish(CommandResult.Failed(INCIDENT_WORK_SCHEDULING_FAILED)) + containCommandActivationFailureLocked( + current = current, + failure = failure, + defaultReason = SafetyPauseReason.WORK_SCHEDULING_FAILURE, + defaultIncidentCode = INCIDENT_WORK_SCHEDULING_FAILED, + ) } } suspend fun pause(): CommandResult = sessionMutex.withLock { val current = requireRuntime() - val result = current.pause() + current.snapshot.value.pendingSafetyPauseReason?.let { reason -> + handleRuntimeSafetyPauseRequestLocked(current, reason) + return@withLock CommandResult.Failed(reason.incidentCode()) + } + val teardownPrearmed = current.snapshot.value.metadata?.state == ExperimentState.RUNNING + if (teardownPrearmed) { + armTeardownSafetyWitnessLocked(current)?.let { + return@withLock it + } + } + val result = try { + current.pause() + } catch (cancellation: CancellationException) { + if (teardownPrearmed) { + containPrearmedTeardownCancellationLocked(current, cancellation) + } + throw cancellation + } + current.snapshot.value.pendingSafetyPauseReason?.let { reason -> + handleRuntimeSafetyPauseRequestLocked(current, reason) + return@withLock CommandResult.Failed(reason.incidentCode()) + } if (result == CommandResult.Success) { - collectionHost.stop() - workScheduler.cancelInterventionWork( - current.configuration.experimentId, - occurrenceIds(current), - ) + finishPrearmedTeardownLocked(current)?.let { return@withLock it } } publish(result) } suspend fun resume(): CommandResult = sessionMutex.withLock { - refreshAccess() val current = requireRuntime() + if (!retrySafetyPauseLocked(current.configuration.experimentId)) { + return@withLock CommandResult.Failed(INCIDENT_SAFETY_PAUSE_PENDING) + } + val lifetime = try { + studyLifetime(current.configuration, requireNotNull(current.snapshot.value.metadata), current.now()) + } catch (failure: Throwable) { + return@withLock containCommandActivationFailureLocked( + current = current, + failure = failure, + defaultReason = SafetyPauseReason.WORK_SCHEDULING_FAILURE, + defaultIncidentCode = INCIDENT_WORK_SCHEDULING_FAILED, + ) + } + if (lifetime.elapsed) { + return@withLock completeExpiredStudyLocked(current) + } + refreshAccessLocked() + if (!requiredAccessReady()) { + return@withLock publish(CommandResult.Failed(INCIDENT_REQUIRED_ACCESS_MISSING)) + } try { - collectionHost.start(current.configuration.title, usesLocation()) + // A paused study already carries the immutable participant-start boundary, so repair + // the deadline/upload/daily work before reopening either the host or a collector. + ensureCollectionWorkLocked(current) + } catch (failure: Throwable) { + return@withLock containCommandActivationFailureLocked( + current = current, + failure = failure, + defaultReason = SafetyPauseReason.WORK_SCHEDULING_FAILURE, + defaultIncidentCode = INCIDENT_WORK_SCHEDULING_FAILED, + ) + } + val availableAccess = currentGrantedKinds() + try { + ensureCollectionHostLocked(current.configuration.title, usesLocation(availableAccess)) } catch (failure: Throwable) { failure.rethrowCancellation() return@withLock publish(CommandResult.Failed(INCIDENT_COLLECTION_HOST_FAILED)) } - val result = current.resume() + val result = try { + current.resume(availableAccess) + } catch (failure: Throwable) { + return@withLock containCommandActivationFailureLocked( + current = current, + failure = failure, + defaultReason = SafetyPauseReason.COLLECTION_HOST_FAILURE, + defaultIncidentCode = INCIDENT_COLLECTION_HOST_FAILED, + ) + } if (result != CommandResult.Success) { - collectionHost.stop() + current.snapshot.value.pendingSafetyPauseReason?.let { + return@withLock containCommandActivationFailureLocked( + current = current, + failure = CommandActivationFailure(result), + defaultReason = it, + defaultIncidentCode = it.incidentCode(), + ) + } + withContext(NonCancellable) { + runAllCleanupSteps( + { stopCollectionHostLocked() }, + { + workScheduler.cancelInterventionWork( + current.configuration.experimentId, + occurrenceIds(current), + ) + }, + ) + } } else { - syncInterventionsLocked(current) + try { + syncInterventionsLocked(current) + } catch (failure: Throwable) { + return@withLock containCommandActivationFailureLocked( + current = current, + failure = failure, + defaultReason = SafetyPauseReason.WORK_SCHEDULING_FAILURE, + defaultIncidentCode = INCIDENT_WORK_SCHEDULING_FAILED, + ) + } } publish(result) } - suspend fun rescheduleInterventions(recoverStalePosting: Boolean = false) = sessionMutex.withLock { + suspend fun reconcileScheduledWork(recoverStalePosting: Boolean = false) = sessionMutex.withLock { runtime?.let { current -> - if (current.snapshot.value.metadata?.state == ExperimentState.RUNNING) { - syncInterventionsLocked(current, recoverStalePosting) - } else { - workScheduler.cancelInterventionWork( - current.configuration.experimentId, - occurrenceIds(current), - ) + val metadata = current.snapshot.value.metadata ?: return@let + try { + if (metadata.state in ACTIVE_STUDY_STATES && + studyLifetime(current.configuration, metadata, current.now()).elapsed + ) { + completeExpiredStudyLocked(current) + } else if ( + metadata.state == ExperimentState.RUNNING && + current.snapshot.value.incidentCode == null + ) { + ensureCollectionWorkLocked(current) + syncInterventionsLocked(current, recoverStalePosting) + } else if (metadata.state == ExperimentState.PAUSED) { + ensureCollectionWorkLocked(current) + workScheduler.cancelInterventionWork( + current.configuration.experimentId, + occurrenceIds(current), + ) + } else if (metadata.state in TERMINAL_STATES) { + workScheduler.cancelCollectionWork( + current.configuration.experimentId, + occurrenceIds(current), + ) + if (metadata.hasParticipantStarted()) ensureCollectionWorkLocked(current) + } else { + workScheduler.cancelInterventionWork( + current.configuration.experimentId, + occurrenceIds(current), + ) + } + } catch (failure: Throwable) { + if (current.snapshot.value.metadata?.state == ExperimentState.RUNNING) { + containRunningSideEffectFailureLocked(current, failure) + } + if (current.snapshot.value.metadata?.state == ExperimentState.PAUSED) { + containCommandActivationFailureLocked( + current = current, + failure = failure, + defaultReason = SafetyPauseReason.WORK_SCHEDULING_FAILURE, + defaultIncidentCode = INCIDENT_WORK_SCHEDULING_FAILED, + ) + return@let + } + throw failure } } } @@ -366,7 +666,11 @@ class StudySessionManager( suspend fun scheduleSuccessor(occurrenceId: String) = sessionMutex.withLock { val current = requireRuntime() if (current.snapshot.value.metadata?.state == ExperimentState.RUNNING) { - scheduleNextLocked(current, occurrenceId) + try { + scheduleNextLocked(current, occurrenceId) + } catch (failure: Throwable) { + containRunningSideEffectFailureLocked(current, failure) + } } } @@ -384,7 +688,42 @@ class StudySessionManager( sessionMutex.withLock { requireRuntime().surveySubmissionEvent(occurrenceId) } suspend fun finish(): CommandResult = terminalCommand { it.finishEarly() } - suspend fun completeAfterDuration(): CommandResult = terminalCommand { it.completeAfterDuration() } + + /** + * Completes an active study only after proving the exact same-boot monotonic deadline is due. + * + * WorkManager delays are advisory and may wake early. Keeping this check inside the session + * lock prevents an early or stale worker from creating a terminal transition. + */ + suspend fun completeAfterDurationIfDue(): DurationCompletionResult = sessionMutex.withLock { + val current = requireRuntime() + val metadata = requireNotNull(current.snapshot.value.metadata) + if (metadata.state !in ACTIVE_STUDY_STATES) { + return@withLock DurationCompletionResult.Inactive + } + val lifetime = try { + studyLifetime(current.configuration, metadata, current.now()) + } catch (failure: Throwable) { + val result = containCommandActivationFailureLocked( + current = current, + failure = failure, + defaultReason = SafetyPauseReason.WORK_SCHEDULING_FAILURE, + defaultIncidentCode = INCIDENT_WORK_SCHEDULING_FAILED, + ) + return@withLock when (result) { + CommandResult.Success -> error("Safety containment returned an impossible success result") + is CommandResult.Failed -> DurationCompletionResult.Failed(result) + } + } + if (!lifetime.elapsed) { + return@withLock DurationCompletionResult.NotDue(lifetime.remainingMillis) + } + when (val result = completeExpiredStudyLocked(current)) { + CommandResult.Success -> DurationCompletionResult.Completed + is CommandResult.Failed -> DurationCompletionResult.Failed(result) + } + } + suspend fun withdraw(): CommandResult = terminalCommand { it.withdraw() } suspend fun exportTo(destination: OutputStream): ExportReceipt = sessionMutex.withLock { @@ -641,18 +980,461 @@ class StudySessionManager( } } - attempt { collectionHost.stop() } + attempt { stopCollectionHostLocked() } attempt { workScheduler.cancel(deletion.experimentId) } attempt { uploader.clear() } + attempt { clearSafetyPauseLocked(deletion.experimentId) } attempt { store.clear() } if (firstFailure == null) attempt { activeStudyStore.clear() } firstFailure?.let { throw it } } - fun refreshAccess() { + suspend fun reconcileAccess() = sessionMutex.withLock { + runtime?.let { current -> + current.snapshot.value.pendingSafetyPauseReason?.let { reason -> + if (!handleRuntimeSafetyPauseRequestLocked(current, reason)) { + return@withLock + } + } + } + runtime?.let { pending -> + if (!retrySafetyPauseLocked(pending.configuration.experimentId)) { + return@withLock + } + } + val current = runtime ?: return@withLock + val metadata = current.snapshot.value.metadata + if (metadata != null && metadata.state in ACTIVE_STUDY_STATES) { + val lifetime = try { + studyLifetime(current.configuration, metadata, current.now()) + } catch (failure: Throwable) { + containCommandActivationFailureLocked( + current = current, + failure = failure, + defaultReason = SafetyPauseReason.WORK_SCHEDULING_FAILURE, + defaultIncidentCode = INCIDENT_WORK_SCHEDULING_FAILED, + ) + return@withLock + } + if (lifetime.elapsed) { + completeExpiredStudyLocked(current) + return@withLock + } + } + try { + refreshAccessLocked() + } catch (failure: Throwable) { + failure.rethrowCancellation() + runtime?.takeIf { current -> + current.snapshot.value.metadata?.state == ExperimentState.RUNNING + }?.let { current -> + pauseForAccessLossLocked(current, INCIDENT_ACCESS_INSPECTION_FAILED) + } ?: publish(CommandResult.Failed(INCIDENT_ACCESS_INSPECTION_FAILED)) + throw failure + } + if (requiredAccessReady()) { + if ( + current.snapshot.value.metadata?.state == ExperimentState.RUNNING && + current.snapshot.value.incidentCode == null + ) { + val result = reconcileRunningCollectorsLocked(current) + if (result != CommandResult.Success) { + publish(result) + return@withLock + } + } + mutableSnapshot.update { snapshot -> + if (snapshot.incidentCode == INCIDENT_REQUIRED_ACCESS_MISSING) { + snapshot.copy(incidentCode = null) + } else { + snapshot + } + } + return@withLock + } + if (current.snapshot.value.metadata?.state == ExperimentState.RUNNING) { + pauseForAccessLossLocked(current, INCIDENT_REQUIRED_ACCESS_MISSING) + } + } + + /** + * Revalidates a service intent that Android redelivered from a prior service/process lifetime. + * The service remains on a neutral restoration notification until this returns true. + */ + suspend fun reconcileRedeliveredCollectionHost(): Boolean = sessionMutex.withLock { + collectionHostStarted = false + collectionHostUsesLocation = false + val current = runtime ?: return@withLock false + if (current.snapshot.value.metadata?.state != ExperimentState.RUNNING) return@withLock false + try { + refreshAccessLocked() + } catch (failure: Throwable) { + failure.rethrowCancellation() + pauseForAccessLossLocked(current, INCIDENT_ACCESS_INSPECTION_FAILED) + return@withLock false + } + if (!requiredAccessReady()) { + pauseForAccessLossLocked(current, INCIDENT_REQUIRED_ACCESS_MISSING) + return@withLock false + } + val result = reconcileRunningCollectorsLocked(current) + publish(result) + result == CommandResult.Success + } + + private suspend fun reconcileRunningCollectorsLocked(current: ExperimentRuntime): CommandResult { + val availableAccess = currentGrantedKinds() + val needsLocationHost = usesLocation(availableAccess) + if (needsLocationHost) { + try { + ensureCollectionHostLocked(current.configuration.title, usesLocation = true) + } catch (failure: Throwable) { + if (failure is CancellationException) { + containHostReconciliationCancellationLocked(current, failure) + } + // Keep every location collector behind its closed access boundary while allowing + // unrelated optional collectors to reconcile normally. + current.reconcileCollectorAccess(availableAccess - LOCATION_COLLECTION_ACCESS) + try { + ensureCollectionHostLocked(current.configuration.title, usesLocation = false) + } catch (recoveryFailure: Throwable) { + if (recoveryFailure is CancellationException) { + containHostReconciliationCancellationLocked(current, recoveryFailure) + } + failure.addSuppressed(recoveryFailure) + return pauseForSafetyFailureLocked( + current = current, + reason = SafetyPauseReason.COLLECTION_HOST_FAILURE, + incidentCode = INCIDENT_COLLECTION_HOST_FAILED, + ) + } + return CommandResult.Failed(INCIDENT_COLLECTION_HOST_FAILED) + } + } + + val result = current.reconcileCollectorAccess(availableAccess) + if (result != CommandResult.Success) return result + + if (!needsLocationHost) { + try { + // Location collectors have already been paused and gated before the platform type + // is removed. This also restores a non-location host after service redelivery. + ensureCollectionHostLocked(current.configuration.title, usesLocation = false) + } catch (failure: Throwable) { + if (failure is CancellationException) { + containHostReconciliationCancellationLocked(current, failure) + } + return pauseForSafetyFailureLocked( + current = current, + reason = SafetyPauseReason.COLLECTION_HOST_FAILURE, + incidentCode = INCIDENT_COLLECTION_HOST_FAILED, + ) + } + } + return CommandResult.Success + } + + private suspend fun containHostReconciliationCancellationLocked( + current: ExperimentRuntime, + cancellation: CancellationException, + ): Nothing { + try { + withContext(NonCancellable) { + establishSafetyPauseLocked( + current = current, + reason = SafetyPauseReason.COLLECTION_HOST_FAILURE, + incidentCode = INCIDENT_COLLECTION_HOST_FAILED, + ) + } + } catch (containmentFailure: Throwable) { + cancellation.addSuppressed(containmentFailure) + } + throw cancellation + } + + private suspend fun pauseForAccessLossLocked( + current: ExperimentRuntime, + incidentCode: String, + ): CommandResult = pauseForSafetyFailureLocked( + current = current, + reason = SafetyPauseReason.REQUIRED_ACCESS_MISSING, + incidentCode = incidentCode, + ) + + private suspend fun pauseForSafetyFailureLocked( + current: ExperimentRuntime, + reason: SafetyPauseReason, + incidentCode: String, + ): CommandResult = establishSafetyPauseLocked(current, reason, incidentCode).commandResult + + private data class SafetyPauseProtocolResult( + val commandResult: CommandResult, + val durableHandoff: Boolean, + val effectiveReason: SafetyPauseReason, + val requestAcknowledged: Boolean, + ) + + private suspend fun establishSafetyPauseLocked( + current: ExperimentRuntime, + reason: SafetyPauseReason, + incidentCode: String, + ): SafetyPauseProtocolResult { + val callerContext = currentCoroutineContext() + val result = withContext(NonCancellable) { + establishSafetyPauseNonCancellable(current, reason, incidentCode) + } + callerContext.ensureActive() + return result + } + + private suspend fun establishSafetyPauseNonCancellable( + current: ExperimentRuntime, + reason: SafetyPauseReason, + incidentCode: String, + ): SafetyPauseProtocolResult { + val closedReason = current.closeAdmissionForSafetyFailure(reason) + val effectiveReason = current.snapshot.value.pendingSafetyPauseReason ?: closedReason + if (effectiveReason == null) { + return SafetyPauseProtocolResult( + commandResult = publish(CommandResult.Failed(INCIDENT_COMMAND_REJECTED)), + durableHandoff = false, + effectiveReason = reason, + requestAcknowledged = false, + ) + } + var markerFailure = false + try { + markSafetyPauseLocked(effectiveReason) + } catch (failure: Throwable) { + failure.rethrowCancellation() + markerFailure = true + } + val result = when (current.snapshot.value.metadata?.state) { + ExperimentState.RUNNING -> current.pauseForSafetyFailure(effectiveReason) + ExperimentState.READY, + ExperimentState.PAUSED, + -> current.retrySafetyPause(effectiveReason) + else -> CommandResult.Failed(INCIDENT_COMMAND_REJECTED) + } + val cleanupFailed = cleanupAfterSafetyPauseLocked(current) + var markerCleanupFailure = false + if (result == CommandResult.Success && !cleanupFailed) { + try { + clearSafetyPauseLocked() + } catch (failure: Throwable) { + failure.rethrowCancellation() + markerCleanupFailure = true + } + } + var retrySchedulingFailure = false + var retryScheduled = false + if (result != CommandResult.Success || cleanupFailed || markerCleanupFailure) { + try { + workScheduler.scheduleSafetyPauseRetry(current.configuration.experimentId, effectiveReason) + retryScheduled = true + } catch (failure: Throwable) { + failure.rethrowCancellation() + retrySchedulingFailure = true + } + } + val commandResult = publish( + when { + retrySchedulingFailure -> CommandResult.Failed(INCIDENT_SAFETY_PAUSE_RETRY_SCHEDULING_FAILED) + result != CommandResult.Success -> result + markerFailure -> CommandResult.Failed(INCIDENT_SAFETY_PAUSE_MARKER_FAILED) + markerCleanupFailure -> CommandResult.Failed(INCIDENT_SAFETY_PAUSE_MARKER_CLEAR_FAILED) + cleanupFailed -> CommandResult.Failed(INCIDENT_SAFETY_PAUSE_SHUTDOWN_FAILED) + else -> CommandResult.Failed( + if (effectiveReason == reason) incidentCode else effectiveReason.incidentCode(), + ) + }, + ) + val durableHandoff = (result == CommandResult.Success && !cleanupFailed) || retryScheduled + val requestAcknowledged = durableHandoff && + current.acknowledgeSafetyPauseRequest(effectiveReason) + return SafetyPauseProtocolResult( + commandResult = commandResult, + durableHandoff = durableHandoff, + effectiveReason = effectiveReason, + requestAcknowledged = requestAcknowledged, + ) + } + + /** + * Completes a runtime-owned typed request under the session lock. + * + * Returning false leaves the runtime signal intact. The observer retries without relying on a + * presentation incident, while every admission boundary remains closed. + */ + private suspend fun handleRuntimeSafetyPauseRequestLocked( + current: ExperimentRuntime, + reason: SafetyPauseReason, + ): Boolean { + if (runtime !== current || current.snapshot.value.pendingSafetyPauseReason != reason) return true + val metadataState = current.snapshot.value.metadata?.state + return when (metadataState) { + ExperimentState.RUNNING -> establishSafetyPauseLocked( + current = current, + reason = reason, + incidentCode = reason.incidentCode(), + ).requestAcknowledged + + ExperimentState.PAUSED -> { + if (retrySafetyPauseLocked(current.configuration.experimentId, reason)) { + true + } else { + try { + workScheduler.scheduleSafetyPauseRetry(current.configuration.experimentId, reason) + current.acknowledgeSafetyPauseRequest(reason) + } catch (failure: Throwable) { + failure.rethrowCancellation() + publish(CommandResult.Failed(INCIDENT_SAFETY_PAUSE_RETRY_SCHEDULING_FAILED)) + false + } + } + } + + ExperimentState.COMPLETED, + ExperimentState.WITHDRAWN -> false + + else -> false + } + } + + private suspend fun processRuntimeSafetyPauseRequest( + current: ExperimentRuntime, + reason: SafetyPauseReason, + ) { + while (true) { + val completed = sessionMutex.withLock { + handleRuntimeSafetyPauseRequestLocked(current, reason) + } + if (completed) return + delay(RUNTIME_SAFETY_PAUSE_RETRY_DELAY_MILLIS) + } + } + + /** WorkManager entry point; returns false only when the typed safety pause must be retried. */ + suspend fun retrySafetyPause( + experimentId: String, + expectedReason: SafetyPauseReason, + ): Boolean = sessionMutex.withLock { + retrySafetyPauseLocked(experimentId, expectedReason) + } + + private suspend fun retrySafetyPauseLocked( + experimentId: String, + expectedReason: SafetyPauseReason? = (safetyPauseStatus as? SafetyPauseStatus.Pending)?.reason, + ): Boolean { + val current = runtime ?: return true + if (current.configuration.experimentId != experimentId) return true + val durableReason = try { + safetyPauseStore.pendingReason() + } catch (failure: Throwable) { + failure.rethrowCancellation() + safetyPauseStatus = SafetyPauseStatus.MarkerUnreadable + mutableSnapshot.update { + it.copy( + safetyPauseStatus = SafetyPauseStatus.MarkerUnreadable, + incidentCode = INCIDENT_SAFETY_PAUSE_MARKER_UNREADABLE, + ) + } + return false + } + val workReason = try { + workScheduler.pendingSafetyPauseReason(experimentId) + } catch (failure: Throwable) { + failure.rethrowCancellation() + publish(CommandResult.Failed(INCIDENT_SAFETY_PAUSE_RETRY_INSPECTION_FAILED)) + return false + } + val distinctReasons = setOfNotNull(durableReason, workReason, expectedReason) + if (distinctReasons.size > 1) { + publish(CommandResult.Failed(INCIDENT_SAFETY_PAUSE_REASON_CONFLICT)) + return false + } + val reason = distinctReasons.singleOrNull() + if (reason == null) { + if (current.hasPendingSafetyPause()) { + publish(CommandResult.Failed(INCIDENT_SAFETY_PAUSE_MARKER_UNREADABLE)) + return false + } + safetyPauseStatus = null + mutableSnapshot.update { it.copy(safetyPauseStatus = null) } + return true + } + safetyPauseStatus = SafetyPauseStatus.Pending(reason) + mutableSnapshot.update { it.copy(safetyPauseStatus = safetyPauseStatus) } + + val metadata = current.snapshot.value.metadata + val runtimeLatched = metadata?.state in setOf( + ExperimentState.READY, + ExperimentState.RUNNING, + ExperimentState.PAUSED, + ) + val result = when { + runtimeLatched -> + current.retrySafetyPause(reason) + else -> CommandResult.Success + } + if (result != CommandResult.Success) { + publish(result) + return false + } + + val cleanupFailed = cleanupAfterSafetyPauseLocked(current) + if (cleanupFailed) { + publish(CommandResult.Failed(INCIDENT_SAFETY_PAUSE_SHUTDOWN_FAILED)) + return false + } + try { + clearSafetyPauseLocked() + } catch (failure: Throwable) { + failure.rethrowCancellation() + publish(CommandResult.Failed(INCIDENT_SAFETY_PAUSE_MARKER_CLEAR_FAILED)) + return false + } + if (runtimeLatched) { + check(current.acknowledgeSafetyPauseRequest(reason)) { + "Durable safety retry did not clear its matching runtime latch" + } + } + publish(CommandResult.Failed(reason.incidentCode())) + return true + } + + private suspend fun cleanupAfterSafetyPauseLocked(current: ExperimentRuntime): Boolean { + var cleanupFailed = false + try { + stopCollectionHostLocked() + } catch (failure: Throwable) { + failure.rethrowCancellation() + cleanupFailed = true + } + try { + if (current.snapshot.value.metadata?.state in TERMINAL_STATES) { + workScheduler.cancelCollectionWork( + current.configuration.experimentId, + occurrenceIds(current), + ) + } else { + workScheduler.cancelInterventionWork( + current.configuration.experimentId, + occurrenceIds(current), + ) + } + } catch (failure: Throwable) { + failure.rethrowCancellation() + cleanupFailed = true + } + return cleanupFailed + } + + private suspend fun refreshAccessLocked() { val configuration = mutableSnapshot.value.configuration ?: return + val plan = accessPlan(configuration) mutableSnapshot.update { - it.copy(access = accessGateway.inspect(requirements(configuration))) + it.copy(access = inspectAccess(configuration, plan)) } } @@ -674,25 +1456,151 @@ class StudySessionManager( } } val configuration = verified.configuration + val markerReason = (safetyPauseStatus as? SafetyPauseStatus.Pending)?.reason + val workReason = workScheduler.pendingSafetyPauseReason(configuration.experimentId) + check(markerReason == null || workReason == null || markerReason == workReason) { + "Safety-pause marker and active retry have conflicting reasons" + } + val recoveredPendingReason = markerReason ?: workReason + if (recoveredPendingReason != null) { + safetyPauseStatus = SafetyPauseStatus.Pending(recoveredPendingReason) + } configuration.collectors.forEach(collectorRegistry::pluginFor) - val requirements = requirements(configuration) - val access = accessGateway.inspect(requirements) + val plan = accessPlan(configuration) + val inspectionRequest = accessInspectionRequest(configuration, plan) + val access = inspectAccess(plan, inspectionRequest) val createdStore = storeFactory.create(configuration.experimentId, configuration.maximumLocalBytes) var createdRuntime: ExperimentRuntime? = null + var recoveredSafetyPause: SafetyPauseReason? = null try { val created = runtimeFactory.create( configuration, createdStore, - availableAccess = { accessGateway.grantedKinds(requirements) }, + SafetyPauseWitness { reason -> + persistRuntimeSafetyWitness(configuration.experimentId, reason) + }, ) createdRuntime = created - check(created.initialize() == CommandResult.Success) { "Runtime initialization failed" } + check(created.initialize(recoveredPendingReason) == CommandResult.Success) { + "Runtime initialization failed" + } if (persistEnvelope) activeStudyStore.save(envelopeBytes) - if (created.snapshot.value.metadata?.state == ExperimentState.RUNNING) { - collectionHost.start( - configuration.title, - usesLocation(requirements), + val recoveredState = created.snapshot.value.metadata?.state + val pendingReason = recoveredPendingReason + var recoveredRunningAccess: Set? = null + if (recoveredState == ExperimentState.RUNNING) { + val availableAccess = access.filter(StudyAccessStatus::granted) + .mapTo(mutableSetOf()) { it.requirement.kind } + val requiredAccessMissing = access.any { it.requirement.required && !it.granted } + val reason = pendingReason ?: SafetyPauseReason.REQUIRED_ACCESS_MISSING.takeIf { + requiredAccessMissing + } + if (reason != null) { + if (pendingReason == null) { + safetyPauseStatus = SafetyPauseStatus.Pending(reason) + persistRuntimeSafetyWitness(configuration.experimentId, reason) + } + check(created.pauseRecoveredForSafetyFailure(reason) == CommandResult.Success) { + "Recovered running study could not enter its typed safety pause" + } + check(!cleanupAfterSafetyPauseLocked(created)) { + "Recovered safety pause cleanup did not complete" + } + check(created.acknowledgeSafetyPauseRequest(reason)) { + "Recovered safety pause did not clear its in-memory typed latch" + } + recoveredSafetyPause = reason + clearSafetyPauseLocked(configuration.experimentId) + } else { + recoveredRunningAccess = availableAccess + } + } else if (pendingReason != null) { + if (recoveredState in setOf(ExperimentState.READY, ExperimentState.PAUSED)) { + check(created.retrySafetyPause(pendingReason) == CommandResult.Success) { + "Recovered safe study did not acknowledge its typed safety marker" + } + } + check(!cleanupAfterSafetyPauseLocked(created)) { + "Recovered safety pause cleanup did not complete" + } + if (recoveredState in setOf(ExperimentState.READY, ExperimentState.PAUSED)) { + check(created.acknowledgeSafetyPauseRequest(pendingReason)) { + "Recovered safe study did not clear its in-memory typed latch" + } + } + recoveredSafetyPause = pendingReason + clearSafetyPauseLocked(configuration.experimentId) + } + var recoveredMetadata = requireNotNull(created.snapshot.value.metadata) + if (recoveredMetadata.state in ACTIVE_STUDY_STATES) { + val lifetime = try { + studyLifetime(configuration, recoveredMetadata, created.now()) + } catch (failure: Throwable) { + containCommandActivationFailureLocked( + current = created, + failure = failure, + defaultReason = SafetyPauseReason.WORK_SCHEDULING_FAILURE, + defaultIncidentCode = INCIDENT_WORK_SCHEDULING_FAILED, + ) + recoveredRunningAccess = null + recoveredSafetyPause = SafetyPauseReason.WORK_SCHEDULING_FAILURE + recoveredMetadata = requireNotNull(created.snapshot.value.metadata) + null + } + if (lifetime?.elapsed == true) { + check(completeExpiredStudyLocked(created) == CommandResult.Success) { + "Expired recovered study could not complete" + } + recoveredRunningAccess = null + recoveredSafetyPause = null + recoveredMetadata = requireNotNull(created.snapshot.value.metadata) + workScheduler.ensureCollectionWork(configuration, recoveredMetadata, created.now()) + } else if (lifetime != null) { + try { + workScheduler.ensureCollectionWork(configuration, recoveredMetadata, created.now()) + if (recoveredMetadata.state == ExperimentState.PAUSED) { + workScheduler.cancelInterventionWork( + configuration.experimentId, + recoveredMetadata.occurrences.keys, + ) + } + } catch (failure: Throwable) { + if (recoveredMetadata.state != ExperimentState.RUNNING) throw failure + containCommandActivationFailureLocked( + current = created, + failure = failure, + defaultReason = SafetyPauseReason.WORK_SCHEDULING_FAILURE, + defaultIncidentCode = INCIDENT_WORK_SCHEDULING_FAILED, + ) + recoveredRunningAccess = null + recoveredSafetyPause = SafetyPauseReason.WORK_SCHEDULING_FAILURE + } + } + } else if (recoveredMetadata.hasParticipantStarted()) { + // A crash can commit COMPLETED/WITHDRAWN before platform cleanup. Retire every + // collection/deadline/reminder/intervention side effect, then KEEP-repair only the + // encrypted upload tail still owed to the researcher. + workScheduler.cancelCollectionWork( + configuration.experimentId, + recoveredMetadata.occurrences.keys, ) + workScheduler.ensureCollectionWork(configuration, recoveredMetadata, created.now()) + } + recoveredRunningAccess?.let { availableAccess -> + try { + ensureCollectionHostLocked(configuration.title, usesLocation(availableAccess)) + check(created.activateRecoveredRunning(availableAccess) == CommandResult.Success) { + "Recovered running study could not reactivate collectors" + } + } catch (failure: Throwable) { + containCommandActivationFailureLocked( + current = created, + failure = failure, + defaultReason = SafetyPauseReason.COLLECTION_HOST_FAILURE, + defaultIncidentCode = INCIDENT_COLLECTION_HOST_FAILED, + ) + recoveredSafetyPause = SafetyPauseReason.COLLECTION_HOST_FAILURE + } } runtime = created studyStore = createdStore @@ -703,6 +1611,8 @@ class StudySessionManager( runtime = created.snapshot.value, access = access, signerAnchored = verified.signerAnchored, + safetyPauseStatus = safetyPauseStatus, + incidentCode = recoveredSafetyPause?.incidentCode(), ) runtimeObservation?.cancel() runtimeObservation = scope.launch { @@ -710,11 +1620,15 @@ class StudySessionManager( mutableSnapshot.update { current -> if (runtime === created) current.copy(runtime = runtimeSnapshot) else current } + runtimeSnapshot.pendingSafetyPauseReason?.let { reason -> + processRuntimeSafetyPauseRequest(created, reason) + } } } } catch (failure: Throwable) { failure.rethrowCancellation() createdRuntime?.let { created -> suppressCleanupFailure(failure) { created.shutdown() } } + if (collectionHostStarted) suppressCleanupFailure(failure) { stopCollectionHostLocked() } if (persistEnvelope) { suppressCleanupFailure(failure, createdStore::clear) suppressCleanupFailure(failure, activeStudyStore::clear) @@ -725,7 +1639,7 @@ class StudySessionManager( private suspend fun command( execute: suspend (ExperimentRuntime) -> CommandResult, - onSuccess: () -> Unit = {}, + onSuccess: suspend () -> Unit = {}, ): CommandResult = sessionMutex.withLock { val result = execute(requireRuntime()) if (result == CommandResult.Success) onSuccess() @@ -766,6 +1680,170 @@ class StudySessionManager( workScheduler.replaceInterventionWork(current.configuration, deliveries, expiries) } + private suspend fun containRunningSideEffectFailureLocked( + current: ExperimentRuntime, + failure: Throwable, + ): Nothing { + val pendingReason = current.snapshot.value.pendingSafetyPauseReason + try { + withContext(NonCancellable) { + establishSafetyPauseLocked( + current = current, + reason = pendingReason ?: SafetyPauseReason.WORK_SCHEDULING_FAILURE, + incidentCode = pendingReason?.incidentCode() ?: INCIDENT_WORK_SCHEDULING_FAILED, + ) + } + } catch (containmentFailure: Throwable) { + failure.addSuppressed(containmentFailure) + } + throw failure + } + + /** + * Arms a process-death witness before any participant pause or terminal source teardown. + * + * This deliberately does not latch the runtime reason: when the command completes normally, + * its durable transition remains PARTICIPANT_PAUSED or the requested terminal reason. A marker + * write is sufficient; acknowledged WorkManager persistence is the closed fallback. + */ + private suspend fun armTeardownSafetyWitnessLocked(current: ExperimentRuntime): CommandResult? { + val callerContext = currentCoroutineContext() + val failure = withContext(NonCancellable) { + try { + persistRuntimeSafetyWitness( + current.configuration.experimentId, + SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE, + ) + safetyPauseStatus = SafetyPauseStatus.Pending( + SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE, + ) + mutableSnapshot.update { it.copy(safetyPauseStatus = safetyPauseStatus) } + null + } catch (caught: Throwable) { + caught + } + } + if (failure != null) { + callerContext.ensureActive() + failure.rethrowCancellation() + return publish(CommandResult.Failed(INCIDENT_SAFETY_PAUSE_RETRY_SCHEDULING_FAILED)) + } + try { + callerContext.ensureActive() + } catch (cancellation: CancellationException) { + containPrearmedTeardownCancellationLocked(current, cancellation) + } + return null + } + + private suspend fun containPrearmedTeardownCancellationLocked( + current: ExperimentRuntime, + cancellation: CancellationException, + ): Nothing { + try { + withContext(NonCancellable) { + establishSafetyPauseLocked( + current = current, + reason = current.snapshot.value.pendingSafetyPauseReason + ?: SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE, + incidentCode = current.snapshot.value.pendingSafetyPauseReason?.incidentCode() + ?: INCIDENT_COLLECTION_TEARDOWN_FAILED, + ) + } + } catch (containmentFailure: Throwable) { + cancellation.addSuppressed(containmentFailure) + } + throw cancellation + } + + /** + * Finishes cleanup after a pre-armed teardown and retires its witness only after every + * platform mutation is acknowledged. A failed cleanup receives an autonomous typed retry. + */ + private suspend fun finishPrearmedTeardownLocked( + current: ExperimentRuntime, + cancelCollectionWork: Boolean = false, + ): CommandResult? = withContext(NonCancellable) { + val cleanupFailure = try { + runAllCleanupSteps( + { stopCollectionHostLocked() }, + { + if (cancelCollectionWork) { + // Terminal collection work retires; upload work intentionally survives. + workScheduler.cancelCollectionWork( + current.configuration.experimentId, + occurrenceIds(current), + ) + } else { + workScheduler.cancelInterventionWork( + current.configuration.experimentId, + occurrenceIds(current), + ) + } + }, + ) + null + } catch (failure: Throwable) { + failure + } + if (cleanupFailure != null) { + return@withContext try { + workScheduler.scheduleSafetyPauseRetry( + current.configuration.experimentId, + SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE, + ) + publish(CommandResult.Failed(INCIDENT_SAFETY_PAUSE_SHUTDOWN_FAILED)) + } catch (schedulingFailure: Throwable) { + cleanupFailure.addSuppressed(schedulingFailure) + publish(CommandResult.Failed(INCIDENT_SAFETY_PAUSE_RETRY_SCHEDULING_FAILED)) + } + } + try { + // Marker first, then awaited retry cancellation: either side of a crash retains a + // witness until the durable participant/terminal boundary and cleanup are complete. + clearSafetyPauseLocked(current.configuration.experimentId) + null + } catch (failure: Throwable) { + publish(CommandResult.Failed(INCIDENT_SAFETY_PAUSE_MARKER_CLEAR_FAILED)) + } + } + + private suspend fun containCommandActivationFailureLocked( + current: ExperimentRuntime, + failure: Throwable, + defaultReason: SafetyPauseReason, + defaultIncidentCode: String, + ): CommandResult { + val pendingReason = current.snapshot.value.pendingSafetyPauseReason + var protocol: SafetyPauseProtocolResult? = null + var containmentFailure: Throwable? = null + withContext(NonCancellable) { + try { + protocol = establishSafetyPauseLocked( + current = current, + reason = pendingReason ?: defaultReason, + incidentCode = pendingReason?.incidentCode() ?: defaultIncidentCode, + ) + } catch (caught: Throwable) { + containmentFailure = caught + } + } + containmentFailure?.let(failure::addSuppressed) + failure.rethrowCancellation() + return protocol?.commandResult ?: throw failure + } + + private class CommandActivationFailure(result: CommandResult) : + IllegalStateException("Runtime activation failed: $result") + + private suspend fun ensureCollectionWorkLocked(current: ExperimentRuntime) { + workScheduler.ensureCollectionWork( + configuration = current.configuration, + metadata = requireNotNull(current.snapshot.value.metadata), + observedAt = current.now(), + ) + } + private suspend fun scheduleNextLocked(current: ExperimentRuntime, completedOccurrenceId: String) { val triggerId = current.snapshot.value.metadata?.occurrences?.get(completedOccurrenceId)?.triggerId ?: return val metadata = current.snapshot.value.metadata ?: return @@ -783,36 +1861,241 @@ class StudySessionManager( execute: suspend (ExperimentRuntime) -> CommandResult, ): CommandResult = sessionMutex.withLock { val current = requireRuntime() - val result = execute(current) + executeTerminalCommandLocked(current, execute) + } + + private suspend fun completeExpiredStudyLocked(current: ExperimentRuntime): CommandResult = + executeTerminalCommandLocked(current) { it.completeAfterDuration() } + + private suspend fun executeTerminalCommandLocked( + current: ExperimentRuntime, + execute: suspend (ExperimentRuntime) -> CommandResult, + ): CommandResult { + val prearmed = current.snapshot.value.metadata?.state in ACTIVE_STUDY_STATES + if (prearmed) { + armTeardownSafetyWitnessLocked(current)?.let { return it } + } + val result = try { + execute(current) + } catch (cancellation: CancellationException) { + if (prearmed) containPrearmedTeardownCancellationLocked(current, cancellation) + throw cancellation + } + return finishTerminalCommandLocked(current, result, prearmed) + } + + private suspend fun finishTerminalCommandLocked( + current: ExperimentRuntime, + result: CommandResult, + prearmed: Boolean, + ): CommandResult { + current.snapshot.value.pendingSafetyPauseReason?.let { reason -> + handleRuntimeSafetyPauseRequestLocked(current, reason) + return CommandResult.Failed(reason.incidentCode()) + } if (result == CommandResult.Success) { - collectionHost.stop() - // Not cancel(): the study is over, but its undelivered tail is not. - workScheduler.cancelCollectionWork( - current.configuration.experimentId, - occurrenceIds(current), - ) + if (prearmed) { + finishPrearmedTeardownLocked( + current = current, + cancelCollectionWork = true, + )?.let { return it } + } else { + withContext(NonCancellable) { + runAllCleanupSteps( + { stopCollectionHostLocked() }, + { + // Not cancel(): the study is over, but its undelivered tail is not. + workScheduler.cancelCollectionWork( + current.configuration.experimentId, + occurrenceIds(current), + ) + }, + { clearSafetyPauseLocked(current.configuration.experimentId) }, + ) + } + } } - publish(result) + return publish(result) + } + + private suspend fun runAllCleanupSteps(vararg steps: suspend () -> Unit) { + var firstFailure: Throwable? = null + steps.forEach { step -> + try { + step() + } catch (failure: Throwable) { + val existing = firstFailure + if (existing == null) firstFailure = failure else existing.addSuppressed(failure) + } + } + firstFailure?.let { throw it } } private fun occurrenceIds(current: ExperimentRuntime): Set = current.snapshot.value.metadata?.occurrences?.keys.orEmpty() - private fun requirements(configuration: StudyConfiguration): Set = - accessPolicy.requirements( - configuration, - collectorRegistry.accessRequirements(configuration.collectors), + private fun StudyMetadata.hasParticipantStarted(): Boolean = + transitions.any { it.reason == cool.jacoblin.particeps.core.model.TransitionReason.PARTICIPANT_STARTED } + + private fun SafetyPauseReason.incidentCode(): String = when (this) { + SafetyPauseReason.REQUIRED_ACCESS_MISSING -> INCIDENT_REQUIRED_ACCESS_MISSING + SafetyPauseReason.COLLECTION_HOST_FAILURE -> INCIDENT_COLLECTION_HOST_FAILED + SafetyPauseReason.WORK_SCHEDULING_FAILURE -> INCIDENT_WORK_SCHEDULING_FAILED + SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE -> INCIDENT_COLLECTION_TEARDOWN_FAILED + SafetyPauseReason.STORAGE_FAILURE -> INCIDENT_STORAGE_WRITE_FAILED + } + + private fun accessPlan(configuration: StudyConfiguration): List = + accessPolicy.plan(collectorRegistry.accessRequirements(configuration.collectors)) + + private fun requirements(plan: List): Set = + plan.mapTo(mutableSetOf(), StudyAccessPlanItem::requirement) + + private fun accessInspectionRequest( + configuration: StudyConfiguration, + plan: List, + ): AccessInspectionRequest { + val requirements = requirements(plan) + val requestedKinds = requirements.mapTo(mutableSetOf(), AccessRequirement::kind) + val locationProfile = if (AccessKind.LOCATION_SERVICES in requestedKinds) { + val location = configuration.collectors.filterIsInstance().single() + LocationAccessProfile.from(location) + } else { + null + } + val notificationFeatures = if (AccessKind.NOTIFICATIONS in requestedKinds) { + buildSet { + add(NotificationAccessFeature.COLLECTION) + add(NotificationAccessFeature.DAILY_STATUS) + if (configuration.interventions.isNotEmpty()) { + add(NotificationAccessFeature.INTERVENTIONS) + } + } + } else { + emptySet() + } + return AccessInspectionRequest( + requirements = requirements, + locationProfile = locationProfile, + notificationFeatures = notificationFeatures, ) + } + + private suspend fun inspectAccess( + configuration: StudyConfiguration, + plan: List, + ): List = inspectAccess(plan, accessInspectionRequest(configuration, plan)) + + private suspend fun inspectAccess( + plan: List, + request: AccessInspectionRequest, + ): List { + val planByKind = plan.associateBy { it.requirement.kind } + require(planByKind.size == plan.size) { "Access plan contains duplicate kinds" } + val inspected = accessGateway.inspect(request).statuses + val inspectedByKind = inspected.associateBy { it.requirement.kind } + require(inspectedByKind.size == inspected.size) { "Access inspection contains duplicate kinds" } + require(inspectedByKind.keys == planByKind.keys) { + "Access inspection must return every planned kind and no others" + } + return inspected.map { status -> + val planned = planByKind.getValue(status.requirement.kind) + require(status.requirement == planned.requirement) { + "Access inspection changed planned requiredness for ${status.requirement.kind}" + } + StudyAccessStatus( + requirement = planned.requirement, + owners = planned.owners, + resolution = status.resolution, + guidance = status.guidance, + ) + } + } + + private fun currentGrantedKinds(): Set = mutableSnapshot.value.access + .filter(StudyAccessStatus::granted) + .mapTo(mutableSetOf()) { it.requirement.kind } + + private fun requiredAccessReady(): Boolean = + mutableSnapshot.value.access.none { it.requirement.required && !it.granted } + + private fun usesLocation(access: List): Boolean { + val grantedKinds = access.filter(StudyAccessStatus::granted) + .mapTo(mutableSetOf()) { it.requirement.kind } + return usesLocation(grantedKinds) + } + + private fun usesLocation(access: Set): Boolean = + LOCATION_COLLECTION_ACCESS.all(access::contains) - private fun currentGrantedKinds(): Set = - accessGateway.grantedKinds(requirements(requireNotNull(mutableSnapshot.value.configuration))) + private suspend fun ensureCollectionHostLocked(studyTitle: String, usesLocation: Boolean) { + if (collectionHostStarted && collectionHostUsesLocation == usesLocation) return + try { + collectionHost.start(studyTitle, usesLocation) + } catch (failure: Throwable) { + collectionHostStarted = false + collectionHostUsesLocation = false + throw failure + } + collectionHostStarted = true + collectionHostUsesLocation = usesLocation + } - private fun usesLocation(): Boolean = - usesLocation(requirements(requireNotNull(mutableSnapshot.value.configuration))) + private fun stopCollectionHostLocked() { + collectionHost.stop() + collectionHostStarted = false + collectionHostUsesLocation = false + } - private fun usesLocation(requirements: Set): Boolean = - requirements.any { it.kind == AccessKind.FINE_LOCATION } && - AccessKind.FINE_LOCATION in accessGateway.grantedKinds(requirements) + private suspend fun markSafetyPauseLocked(reason: SafetyPauseReason) { + safetyPauseStatus = SafetyPauseStatus.Pending(reason) + mutableSnapshot.update { it.copy(safetyPauseStatus = safetyPauseStatus) } + safetyPauseStore.markPending(reason) + } + + /** + * Gives runtime storage failures a process-death witness before their failing operation returns. + * This port never acquires sessionMutex, so runtime may safely invoke it while holding its + * metadata mutex. A confirmed typed work request is the only fallback when the marker fails. + */ + private suspend fun persistRuntimeSafetyWitness( + experimentId: String, + reason: SafetyPauseReason, + ) { + try { + val existing = safetyPauseStore.pendingReason() + check( + existing == null || + existing == reason || + existing == SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE, + ) { + "Runtime safety witness conflicts with the durable marker" + } + if (existing == null) safetyPauseStore.markPending(reason) + } catch (markerFailure: Throwable) { + markerFailure.rethrowCancellation() + try { + workScheduler.scheduleSafetyPauseRetry(experimentId, reason) + } catch (schedulingFailure: Throwable) { + schedulingFailure.rethrowCancellation() + markerFailure.addSuppressed(schedulingFailure) + throw markerFailure + } + } + } + + private suspend fun clearSafetyPauseLocked( + experimentId: String? = mutableSnapshot.value.configuration?.experimentId, + ) = withContext(NonCancellable) { + // If the process dies after clearing the marker but before cancellation commits, the + // active typed work remains the recovery witness. Awaiting cancellation then guarantees a + // resumed study can never be surprised by a stale retry. NonCancellable lets a running + // SafetyPauseWorker retire itself without abandoning this short completion protocol. + safetyPauseStore.clear() + if (experimentId != null) workScheduler.cancelSafetyPauseRetry() + safetyPauseStatus = null + mutableSnapshot.update { it.copy(safetyPauseStatus = null) } + } private fun publish(result: CommandResult): CommandResult { mutableSnapshot.update { @@ -839,6 +2122,12 @@ class StudySessionManager( private companion object { val TERMINAL_STATES = setOf(ExperimentState.COMPLETED, ExperimentState.WITHDRAWN) + val ACTIVE_STUDY_STATES = setOf(ExperimentState.RUNNING, ExperimentState.PAUSED) + val LOCATION_COLLECTION_ACCESS = setOf( + AccessKind.FINE_LOCATION, + AccessKind.LOCATION_SERVICES, + AccessKind.BACKGROUND_LOCATION, + ) val EXPIRABLE_UNOPENED_OCCURRENCE_STATES = setOf( OccurrenceState.SCHEDULED, OccurrenceState.POSTING, @@ -863,13 +2152,28 @@ class StudySessionManager( ) const val INCIDENT_STUDY_RECOVERY_FAILED = "STUDY_RECOVERY_FAILED" const val INCIDENT_STUDY_IMPORT_FAILED = "STUDY_IMPORT_FAILED" + const val INCIDENT_COMMAND_REJECTED = "COMMAND_REJECTED" const val INCIDENT_REQUIRED_ACCESS_MISSING = "REQUIRED_ACCESS_MISSING" + const val INCIDENT_ACCESS_INSPECTION_FAILED = "ACCESS_INSPECTION_FAILED" + const val INCIDENT_SAFETY_PAUSE_SHUTDOWN_FAILED = "SAFETY_PAUSE_SHUTDOWN_FAILED" + const val INCIDENT_SAFETY_PAUSE_PENDING = "SAFETY_PAUSE_PENDING" + const val INCIDENT_SAFETY_PAUSE_MARKER_FAILED = "SAFETY_PAUSE_MARKER_FAILED" + const val INCIDENT_SAFETY_PAUSE_MARKER_UNREADABLE = "SAFETY_PAUSE_MARKER_UNREADABLE" + const val INCIDENT_SAFETY_PAUSE_MARKER_CLEAR_FAILED = "SAFETY_PAUSE_MARKER_CLEAR_FAILED" + const val INCIDENT_SAFETY_PAUSE_REASON_CONFLICT = "SAFETY_PAUSE_REASON_CONFLICT" + const val INCIDENT_SAFETY_PAUSE_RETRY_INSPECTION_FAILED = + "SAFETY_PAUSE_RETRY_INSPECTION_FAILED" + const val INCIDENT_SAFETY_PAUSE_RETRY_SCHEDULING_FAILED = + "SAFETY_PAUSE_RETRY_SCHEDULING_FAILED" const val INCIDENT_COLLECTION_HOST_FAILED = "COLLECTION_HOST_FAILED" + const val INCIDENT_COLLECTION_TEARDOWN_FAILED = "COLLECTION_TEARDOWN_FAILED" + const val INCIDENT_STORAGE_WRITE_FAILED = "STORAGE_WRITE_FAILED" const val INCIDENT_WORK_SCHEDULING_FAILED = "WORK_SCHEDULING_FAILED" const val INCIDENT_UPLOAD_FAILED = "UPLOAD_FAILED" const val INCIDENT_UPLOAD_COMMIT_FAILED = "UPLOAD_COMMIT_FAILED" const val INCIDENT_RECLAIM_FAILED = "LOCAL_RECLAIM_FAILED" const val MAXIMUM_UPLOAD_BYTES = 32L * 1024 * 1024 + const val RUNTIME_SAFETY_PAUSE_RETRY_DELAY_MILLIS = 10_000L val SHA256_HEX = Regex("[0-9a-f]{64}") } diff --git a/core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyLifetime.kt b/core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyLifetime.kt new file mode 100644 index 0000000..e25184b --- /dev/null +++ b/core/study-application/src/main/kotlin/cool/jacoblin/particeps/core/application/StudyLifetime.kt @@ -0,0 +1,68 @@ +package cool.jacoblin.particeps.core.application + +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.TransitionReason + +/** The durable, absolute study lifetime derived from the participant's one explicit start. */ +data class StudyLifetime( + val participantStartedAt: ResearchTime, + val elapsedMillis: Long, + val remainingMillis: Long, +) { + val elapsed: Boolean get() = remainingMillis == 0L +} + +/** + * Derives the signed study duration from durable transition history without resetting it on resume. + * + * Android's monotonic clock is authoritative only while both observations belong to one boot. + * Wall time cannot safely bridge a reboot: it may have moved backwards without crossing the + * participant-start wall timestamp, which would extend the signed duration. Callers therefore + * fail closed after a reboot instead of manufacturing a later deadline from an untrusted clock. + */ +fun studyLifetime( + configuration: StudyConfiguration, + metadata: StudyMetadata, + observedAt: ResearchTime, +): StudyLifetime { + require(metadata.experimentId == configuration.experimentId) { "Experiment ID mismatch" } + require(metadata.configurationId == configuration.configurationId) { "Configuration ID mismatch" } + require(metadata.state in STARTED_STUDY_STATES) { "Study has not started" } + val start = participantStartedAt(metadata) + require(start.bootSessionId == observedAt.bootSessionId) { + "Study duration cannot be proven across boot sessions" + } + require(observedAt.elapsedRealtimeNanos >= start.elapsedRealtimeNanos) { + "Monotonic study clock moved backwards" + } + val elapsedMillis = + (observedAt.elapsedRealtimeNanos - start.elapsedRealtimeNanos) / NANOS_PER_MILLISECOND + val durationMillis = configuration.durationHours.toLong() * MILLIS_PER_HOUR + return StudyLifetime( + participantStartedAt = start, + elapsedMillis = elapsedMillis, + remainingMillis = (durationMillis - elapsedMillis).coerceAtLeast(0L), + ) +} + +/** Returns the one immutable participant-start boundary after validating its state transition. */ +fun participantStartedAt(metadata: StudyMetadata): ResearchTime { + val start = metadata.transitions.singleOrNull { it.reason == TransitionReason.PARTICIPANT_STARTED } + ?: error("Started study must contain exactly one participant-start transition") + require(start.from == ExperimentState.READY && start.to == ExperimentState.RUNNING) { + "Participant-start transition has an invalid boundary" + } + return start.time +} + +private val STARTED_STUDY_STATES = setOf( + ExperimentState.RUNNING, + ExperimentState.PAUSED, + ExperimentState.COMPLETED, + ExperimentState.WITHDRAWN, +) +private const val MILLIS_PER_HOUR = 60L * 60L * 1_000L +private const val NANOS_PER_MILLISECOND = 1_000_000L diff --git a/core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/InterventionSchedulePlannerTest.kt b/core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/InterventionSchedulePlannerTest.kt index 6a3c685..895bfb8 100644 --- a/core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/InterventionSchedulePlannerTest.kt +++ b/core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/InterventionSchedulePlannerTest.kt @@ -26,6 +26,7 @@ import java.time.ZoneId import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test @@ -111,9 +112,9 @@ class InterventionSchedulePlannerTest { val configuration = configuration(OneTimeSchedule(15, RelativeClock.CALENDAR_TIME)) val metadata = runningMetadata() val first = planner.next(configuration, metadata, at(1), ZoneId.of("UTC")) - val afterProcessDeath = planner.next(configuration, metadata, at(2, "new-boot"), ZoneId.of("UTC")) - assertEquals(first.single().occurrenceId, afterProcessDeath.single().occurrenceId) - assertEquals(first.single().scheduledFor.wallTimeUtcMillis, afterProcessDeath.single().scheduledFor.wallTimeUtcMillis) + val afterProcessRestart = planner.next(configuration, metadata, at(2), ZoneId.of("UTC")) + assertEquals(first.single().occurrenceId, afterProcessRestart.single().occurrenceId) + assertEquals(first.single().scheduledFor.wallTimeUtcMillis, afterProcessRestart.single().scheduledFor.wallTimeUtcMillis) val completed = metadata.copy(state = ExperimentState.COMPLETED) assertTrue(planner.next(configuration, completed, at(2), ZoneId.of("UTC")).isEmpty()) @@ -157,7 +158,7 @@ class InterventionSchedulePlannerTest { val persisted = metadata.copy(occurrences = mapOf(first.occurrenceId to first)) val afterProcessDeath = InterventionSchedulePlanner { 0 } - .next(configuration, persisted, at(5, "new-boot"), ZoneId.of("Pacific/Kiritimati")) + .next(configuration, persisted, at(5), ZoneId.of("Pacific/Kiritimati")) .single() assertEquals(first, afterProcessDeath) @@ -171,6 +172,18 @@ class InterventionSchedulePlannerTest { assertEquals(at(11 * 60 + 59).wallTimeUtcMillis, second.scheduledFor.wallTimeUtcMillis) } + @Test + fun plannerCannotManufactureActiveWorkAcrossABootBoundary() { + assertThrows(IllegalArgumentException::class.java) { + planner.next( + configuration(OneTimeSchedule(15, RelativeClock.CALENDAR_TIME)), + runningMetadata(), + at(2, "new-boot"), + ZoneId.of("UTC"), + ) + } + } + @Test fun randomWindowHonorsTheSignedDailyAndTotalCaps() { val schedule = RandomWindowSchedule( diff --git a/core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/StudyAccessPolicyTest.kt b/core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/StudyAccessPolicyTest.kt new file mode 100644 index 0000000..b314c4b --- /dev/null +++ b/core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/StudyAccessPolicyTest.kt @@ -0,0 +1,56 @@ +package cool.jacoblin.particeps.core.application + +import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.collector.CollectorAccessRequirement +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class StudyAccessPolicyTest { + private val policy = StudyAccessPolicy() + + @Test + fun notificationsAreRequiredEvenWhenNoCollectorNeedsAccess() { + val plan = policy.plan(emptyList()) + val notifications = plan.single() + + assertEquals(AccessKind.NOTIFICATIONS, notifications.requirement.kind) + assertTrue(notifications.requirement.required) + assertEquals( + setOf( + StudyAccessOwner.Feature( + StudyAccessFeature.STUDY_NOTIFICATIONS, + required = true, + ), + ), + notifications.owners, + ) + } + + @Test + fun sharedAccessIsDeduplicatedAndPreservesEveryCollectorOwner() { + val plan = policy.plan( + listOf( + CollectorAccessRequirement( + "network_usage.v1", + AccessRequirement(AccessKind.USAGE_ACCESS, required = true), + ), + CollectorAccessRequirement( + "usage_events.v1", + AccessRequirement(AccessKind.USAGE_ACCESS, required = false), + ), + ), + ) + val usageAccess = plan.single { it.requirement.kind == AccessKind.USAGE_ACCESS } + + assertTrue(usageAccess.requirement.required) + assertEquals( + setOf( + StudyAccessOwner.Collector("network_usage.v1", required = true), + StudyAccessOwner.Collector("usage_events.v1", required = false), + ), + usageAccess.owners, + ) + } +} diff --git a/core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/StudyLifetimeTest.kt b/core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/StudyLifetimeTest.kt new file mode 100644 index 0000000..23959ee --- /dev/null +++ b/core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/StudyLifetimeTest.kt @@ -0,0 +1,117 @@ +package cool.jacoblin.particeps.core.application + +import cool.jacoblin.particeps.core.definition.AppLifecycleConfiguration +import cool.jacoblin.particeps.core.definition.ExportConfiguration +import cool.jacoblin.particeps.core.definition.SignerIdentity +import cool.jacoblin.particeps.core.definition.StudyConfiguration +import cool.jacoblin.particeps.core.model.ExperimentState +import cool.jacoblin.particeps.core.model.ExperimentTransition +import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.StudyMetadata +import cool.jacoblin.particeps.core.model.TransitionReason +import java.time.Instant +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class StudyLifetimeTest { + @Test + fun sameBootUsesMonotonicTimeEvenWhenWallClockMovesBackwards() { + val start = ResearchTime(10_000, 1_000_000_000, "boot-one") + + val lifetime = studyLifetime( + configuration(), + startedMetadata(start), + ResearchTime(1_000, 1_600_000_000, "boot-one"), + ) + + assertEquals(600, lifetime.elapsedMillis) + assertEquals(3_599_400, lifetime.remainingMillis) + } + + @Test + fun rebootFailsClosedEvenWhenWallTimeStillFollowsTheParticipantStart() { + val start = ResearchTime(10_000, 9_000_000_000, "boot-one") + + assertThrows(IllegalArgumentException::class.java) { + studyLifetime( + configuration(), + startedMetadata(start), + ResearchTime(610_000, 100, "boot-two"), + ) + } + } + + @Test + fun elapsedStudyHasZeroRemainingInsteadOfAResetDuration() { + val start = ResearchTime(10_000, 1_000, "boot-one") + + val lifetime = studyLifetime( + configuration(), + startedMetadata(start), + ResearchTime(3_610_000, 3_610_001_000_000, "boot-one"), + ) + + assertTrue(lifetime.elapsed) + assertEquals(0, lifetime.remainingMillis) + } + + @Test + fun duplicateParticipantStartTransitionsAreRejected() { + val start = ResearchTime(10_000, 1_000, "boot-one") + val metadata = startedMetadata(start).let { original -> + original.copy(transitions = original.transitions + original.transitions.single()) + } + + assertThrows(IllegalStateException::class.java) { + studyLifetime(configuration(), metadata, ResearchTime(11_000, 2_000, "boot-one")) + } + } + + private fun startedMetadata(start: ResearchTime): StudyMetadata = StudyMetadata.initial( + EXPERIMENT_ID, + CONFIGURATION_ID, + ).copy( + state = ExperimentState.RUNNING, + transitions = listOf( + ExperimentTransition( + from = ExperimentState.READY, + to = ExperimentState.RUNNING, + reason = TransitionReason.PARTICIPANT_STARTED, + time = start, + ), + ), + ) + + private fun configuration() = StudyConfiguration( + schemaVersion = StudyConfiguration.CURRENT_SCHEMA_VERSION, + experimentId = EXPERIMENT_ID, + configurationId = CONFIGURATION_ID, + issuedAt = Instant.parse("2026-01-01T00:00:00Z"), + expiresAt = Instant.parse("2030-01-01T00:00:00Z"), + platform = StudyConfiguration.ANDROID_PLATFORM, + minimumClientVersion = 1, + title = "Lifetime test", + researcherName = "Researcher", + researcherContact = "researcher@example.invalid", + purpose = "Test the durable study lifetime.", + durationHours = 1, + consentDocumentVersion = "v1", + consentSummary = "Test consent.", + assignedParticipantId = null, + collectors = listOf(AppLifecycleConfiguration(required = true)), + surveys = emptyList(), + interventions = emptyList(), + maximumLocalBytes = 16_777_216, + signer = SignerIdentity("test-signer", RAW_PUBLIC_KEY), + export = ExportConfiguration("export-key", RAW_PUBLIC_KEY), + upload = null, + ) + + private companion object { + const val EXPERIMENT_ID = "lifetime-study" + const val CONFIGURATION_ID = "lifetime-config" + const val RAW_PUBLIC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } +} diff --git a/core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/StudySessionManagerTest.kt b/core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/StudySessionManagerTest.kt index 2ac2e0c..f324d21 100644 --- a/core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/StudySessionManagerTest.kt +++ b/core/study-application/src/test/kotlin/cool/jacoblin/particeps/core/application/StudySessionManagerTest.kt @@ -1,8 +1,12 @@ package cool.jacoblin.particeps.core.application import cool.jacoblin.particeps.core.collector.AccessKind +import cool.jacoblin.particeps.core.collector.AccessInspectionRequest import cool.jacoblin.particeps.core.collector.AccessRequirement +import cool.jacoblin.particeps.core.collector.AccessResolution +import cool.jacoblin.particeps.core.collector.AccessSnapshot import cool.jacoblin.particeps.core.collector.AccessStatus +import cool.jacoblin.particeps.core.collector.AccessUnavailableReason import cool.jacoblin.particeps.core.collector.Collector import cool.jacoblin.particeps.core.collector.CollectorContext import cool.jacoblin.particeps.core.collector.CollectorDescriptor @@ -13,6 +17,7 @@ import cool.jacoblin.particeps.core.collector.CollectorStatus import cool.jacoblin.particeps.core.collector.PrivacyClass import cool.jacoblin.particeps.core.collector.ProtocolEventContracts import cool.jacoblin.particeps.core.collector.ResearchClocks +import cool.jacoblin.particeps.core.collector.NotificationAccessFeature import cool.jacoblin.particeps.core.collector.StudyAccessGateway import cool.jacoblin.particeps.core.definition.AppLifecycleConfiguration import cool.jacoblin.particeps.core.definition.CollectorConfiguration @@ -21,6 +26,8 @@ import cool.jacoblin.particeps.core.definition.InterventionConfiguration import cool.jacoblin.particeps.core.definition.InterventionTrigger import cool.jacoblin.particeps.core.definition.IntervalSchedule import cool.jacoblin.particeps.core.definition.LocalizedText +import cool.jacoblin.particeps.core.definition.LocationConfiguration +import cool.jacoblin.particeps.core.definition.LocationPriority import cool.jacoblin.particeps.core.definition.NotificationAction import cool.jacoblin.particeps.core.definition.OneTimeSchedule import cool.jacoblin.particeps.core.definition.RelativeClock @@ -38,6 +45,7 @@ import cool.jacoblin.particeps.core.model.InterventionOccurrence import cool.jacoblin.particeps.core.model.OccurrenceState import cool.jacoblin.particeps.core.model.RecordedEvent import cool.jacoblin.particeps.core.model.ResearchTime +import cool.jacoblin.particeps.core.model.SafetyPauseReason import cool.jacoblin.particeps.core.model.StorageUsage import cool.jacoblin.particeps.core.model.StudyMetadata import cool.jacoblin.particeps.core.model.StudyStore @@ -55,13 +63,17 @@ import java.net.URI import java.security.MessageDigest import java.time.Instant import java.util.UUID +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -119,6 +131,54 @@ class StudySessionManagerTest { assertTrue((fixture.uploader as FakeUploader).cleared) } + @Test + fun durationCompletionRechecksEarlyAndExactDeadlineInsideTheSessionLock() = runTest { + val fixture = fixture(configuration()) + fixture.prepareRunningStudy() + + fixture.forceDurationBoundary(offsetNanos = -1) + assertEquals( + DurationCompletionResult.NotDue(remainingMillis = 1), + fixture.manager.completeAfterDurationIfDue(), + ) + assertEquals(ExperimentState.RUNNING, fixture.store.metadata?.state) + assertEquals(0, fixture.host.stopCount) + assertEquals(0, fixture.work.cancelCollectionCount) + + fixture.forceDurationBoundary(offsetNanos = 0) + assertEquals( + DurationCompletionResult.Completed, + fixture.manager.completeAfterDurationIfDue(), + ) + assertEquals(ExperimentState.COMPLETED, fixture.store.metadata?.state) + assertEquals( + TransitionReason.STUDY_DURATION_ELAPSED, + fixture.store.metadata?.transitions?.last()?.reason, + ) + assertEquals(1, fixture.host.stopCount) + assertEquals(1, fixture.work.cancelCollectionCount) + } + + @Test + fun accessReconciliationCompletesADelayedDeadlineBeforeInspectingAccess() = runTest { + val fixture = fixture(configuration()) + fixture.prepareRunningStudy() + val inspectionsBeforeDeadline = fixture.access.inspectCount + fixture.access.failure = IllegalStateException("access backend must not run after deadline") + fixture.forceDurationBoundary(offsetNanos = 0) + + fixture.manager.reconcileAccess() + + assertEquals(ExperimentState.COMPLETED, fixture.store.metadata?.state) + assertEquals( + TransitionReason.STUDY_DURATION_ELAPSED, + fixture.store.metadata?.transitions?.last()?.reason, + ) + assertEquals(inspectionsBeforeDeadline, fixture.access.inspectCount) + assertEquals(1, fixture.host.stopCount) + assertEquals(1, fixture.collector.stopCount) + } + @Test fun deletionTombstoneBlocksUploadAndImportUntilCleanupCanFinish() = runTest { val uploader = FakeUploader() @@ -226,60 +286,1187 @@ class StudySessionManagerTest { } @Test - fun interventionNotificationIsPartOfCanonicalRequiredAccess() = runTest { - val configuration = configuration( - interventions = listOf( - InterventionConfiguration( - "notice-one", - NotificationAction("Study check-in", "Check in"), - listOf( - InterventionTrigger( - "after-minute", - OneTimeSchedule(1, RelativeClock.CALENDAR_TIME), - 60, - ), - ), + fun everyStudyRequiresNotificationAccessWithoutInterventions() = runTest { + val fixture = fixture(configuration(), grantedAccess = emptySet()) + fixture.manager.initialize() + fixture.manager.importSignedConfiguration(byteArrayOf(1)) + fixture.manager.reviewStudy() + fixture.manager.acceptConsent() + + val notifications = fixture.manager.snapshot.value.access + .single { it.requirement.kind == AccessKind.NOTIFICATIONS } + assertTrue(notifications.requirement.required) + assertFalse(notifications.granted) + assertEquals( + setOf(NotificationAccessFeature.COLLECTION, NotificationAccessFeature.DAILY_STATUS), + fixture.access.lastRequest?.notificationFeatures, + ) + assertEquals( + CommandResult.Failed("REQUIRED_ACCESS_MISSING"), + fixture.manager.completeAccessSetup(), + ) + runCurrent() + assertEquals(ExperimentState.ACCESS_SETUP, fixture.manager.snapshot.value.runtime.metadata?.state) + + fixture.access.granted += AccessKind.NOTIFICATIONS + assertEquals(CommandResult.Success, fixture.manager.completeAccessSetup()) + runCurrent() + assertEquals(ExperimentState.READY, fixture.manager.snapshot.value.runtime.metadata?.state) + } + + @Test + fun interventionStudyAddsOnlyTheAppOwnedInterventionNotificationFeature() = runTest { + val intervention = InterventionConfiguration( + id = "notice-one", + action = NotificationAction("Study check-in", "Check in"), + triggers = listOf( + InterventionTrigger( + "after-minute", + OneTimeSchedule(1, RelativeClock.CALENDAR_TIME), + availabilityMinutes = 60, ), ), ) - val fixture = fixture(configuration) + val fixture = fixture(configuration(interventions = listOf(intervention))) fixture.manager.initialize() fixture.manager.importSignedConfiguration(byteArrayOf(1)) - fixture.manager.reviewStudy() - fixture.manager.acceptConsent() assertEquals( - CommandResult.Failed("REQUIRED_ACCESS_MISSING"), - fixture.manager.completeAccessSetup(), + NotificationAccessFeature.entries.toSet(), + fixture.access.lastRequest?.notificationFeatures, + ) + } + + @Test + fun incompletePlatformAccessInspectionRejectsStudyActivation() = runTest { + val fixture = fixture( + configuration(), + omittedAccess = setOf(AccessKind.NOTIFICATIONS), + ) + fixture.manager.initialize() + + val failure = runCatching { + fixture.manager.importSignedConfiguration(byteArrayOf(1)) + }.exceptionOrNull() + + assertEquals( + "Access inspection must return every planned kind and no others", + failure?.message, + ) + assertNull(fixture.active.record) + assertNull(fixture.manager.snapshot.value.configuration) + } + + @Test + fun startAndResumeRecheckRequiredNotificationAccess() = runTest { + val fixture = fixture(configuration()) + val manager = fixture.manager + manager.initialize() + manager.importSignedConfiguration(byteArrayOf(1)) + manager.reviewStudy() + manager.acceptConsent() + assertEquals(CommandResult.Success, manager.completeAccessSetup()) + runCurrent() + + fixture.access.granted -= AccessKind.NOTIFICATIONS + assertEquals(CommandResult.Failed("REQUIRED_ACCESS_MISSING"), manager.start()) + assertEquals(ExperimentState.READY, manager.snapshot.value.runtime.metadata?.state) + assertEquals(0, fixture.host.startCount) + + fixture.access.granted += AccessKind.NOTIFICATIONS + val inspectionsBeforeStart = fixture.access.inspectCount + assertEquals(CommandResult.Success, manager.start()) + assertEquals(inspectionsBeforeStart + 1, fixture.access.inspectCount) + assertEquals(CommandResult.Success, manager.pause()) + runCurrent() + + fixture.access.granted -= AccessKind.NOTIFICATIONS + val inspectionsBeforeResume = fixture.access.inspectCount + assertEquals(CommandResult.Failed("REQUIRED_ACCESS_MISSING"), manager.resume()) + assertEquals(inspectionsBeforeResume + 1, fixture.access.inspectCount) + assertEquals(ExperimentState.PAUSED, manager.snapshot.value.runtime.metadata?.state) + assertEquals(1, fixture.host.startCount) + } + + @Test + fun startFailsClosedWhenDurableRetryInspectionFails() = runTest { + val fixture = fixture(configuration()) + val manager = fixture.manager + manager.initialize() + manager.importSignedConfiguration(byteArrayOf(1)) + manager.reviewStudy() + manager.acceptConsent() + manager.completeAccessSetup() + runCurrent() + fixture.work.safetyPauseInspectionFailure = IllegalStateException("work database unavailable") + + assertEquals(CommandResult.Failed("SAFETY_PAUSE_PENDING"), manager.start()) + + assertEquals(ExperimentState.READY, manager.snapshot.value.runtime.metadata?.state) + assertEquals("SAFETY_PAUSE_RETRY_INSPECTION_FAILED", manager.snapshot.value.incidentCode) + assertEquals(0, fixture.host.startCount) + assertEquals(0, fixture.collector.startCount) + } + + @Test + fun foregroundHostMustAcknowledgeBeforeRuntimeBecomesRunning() = runTest { + val fixture = fixture(configuration()) + val manager = fixture.manager + manager.initialize() + manager.importSignedConfiguration(byteArrayOf(1)) + manager.reviewStudy() + manager.acceptConsent() + manager.completeAccessSetup() + fixture.host.startFailure = IllegalStateException("platform rejected foreground start") + + assertEquals(CommandResult.Failed("COLLECTION_HOST_FAILED"), manager.start()) + runCurrent() + + assertEquals(ExperimentState.READY, manager.snapshot.value.runtime.metadata?.state) + assertEquals(0, fixture.collector.startCount) + assertFalse(fixture.lifecycleEvents.contains("collector:start")) + } + + @Test + fun losingRequiredAccessWhileRunningPausesCollectionAndInterventions() = runTest { + val fixture = fixture(configuration()) + val manager = fixture.manager + manager.initialize() + manager.importSignedConfiguration(byteArrayOf(1)) + manager.reviewStudy() + manager.acceptConsent() + manager.completeAccessSetup() + manager.start() + runCurrent() + + fixture.access.granted -= AccessKind.NOTIFICATIONS + manager.reconcileAccess() + runCurrent() + + val metadata = manager.snapshot.value.runtime.metadata + assertEquals(ExperimentState.PAUSED, metadata?.state) + assertEquals(TransitionReason.REQUIRED_ACCESS_MISSING, metadata?.transitions?.last()?.reason) + assertEquals("REQUIRED_ACCESS_MISSING", manager.snapshot.value.incidentCode) + assertEquals(1, fixture.host.stopCount) + assertEquals(1, fixture.work.cancelInterventionCount) + assertEquals(CollectorStatus.PAUSED, manager.snapshot.value.runtime.collectorHealth + .getValue(AppLifecycleConfiguration.ID).status) + } + + @Test + fun resumeWaitsForDurableRetryRetirementBeforeReopeningCollection() = runTest { + val fixture = fixture(configuration()) + val manager = fixture.manager + manager.initialize() + manager.importSignedConfiguration(byteArrayOf(1)) + manager.reviewStudy() + manager.acceptConsent() + manager.completeAccessSetup() + manager.start() + runCurrent() + + fixture.access.granted -= AccessKind.NOTIFICATIONS + manager.reconcileAccess() + runCurrent() + fixture.access.granted += AccessKind.NOTIFICATIONS + fixture.work.activeSafetyPauseReasons += SafetyPauseReason.REQUIRED_ACCESS_MISSING + fixture.work.safetyPauseCancellationGate = CompletableDeferred() + val cancellationsBeforeResume = fixture.work.safetyPauseCancelCount + val hostStartsBeforeResume = fixture.host.startCount + + val resume = async { manager.resume() } + runCurrent() + + assertFalse(resume.isCompleted) + assertEquals(hostStartsBeforeResume, fixture.host.startCount) + assertEquals( + SafetyPauseStatus.Pending(SafetyPauseReason.REQUIRED_ACCESS_MISSING), + manager.snapshot.value.safetyPauseStatus, + ) + + fixture.work.safetyPauseCancellationGate?.complete(Unit) + runCurrent() + + assertEquals(CommandResult.Success, resume.await()) + assertTrue(fixture.work.activeSafetyPauseReasons.isEmpty()) + assertEquals(cancellationsBeforeResume + 1, fixture.work.safetyPauseCancelCount) + assertEquals(hostStartsBeforeResume + 1, fixture.host.startCount) + assertEquals(ExperimentState.RUNNING, manager.snapshot.value.runtime.metadata?.state) + assertEquals(1, fixture.collector.resumeCount) + } + + @Test + fun retryCancellationFailureKeepsSafetyPausePendingAndReenqueuesWitness() = runTest { + val fixture = fixture(configuration()) + val manager = fixture.manager + manager.initialize() + manager.importSignedConfiguration(byteArrayOf(1)) + manager.reviewStudy() + manager.acceptConsent() + manager.completeAccessSetup() + manager.start() + runCurrent() + fixture.work.safetyPauseCancelFailure = IllegalStateException("work cancellation failed") + + fixture.access.granted -= AccessKind.NOTIFICATIONS + manager.reconcileAccess() + runCurrent() + + assertEquals(ExperimentState.PAUSED, manager.snapshot.value.runtime.metadata?.state) + assertEquals( + SafetyPauseStatus.Pending(SafetyPauseReason.REQUIRED_ACCESS_MISSING), + manager.snapshot.value.safetyPauseStatus, + ) + assertEquals(SafetyPauseReason.REQUIRED_ACCESS_MISSING, fixture.work.scheduledSafetyPauseReason) + assertEquals(1, fixture.work.safetyPauseScheduleCount) + } + + @Test + fun accessLossClosesAdmissionAndStopsTheHostWhenPausePersistenceFails() = runTest { + val fixture = fixture(configuration()) + val manager = fixture.manager + manager.initialize() + manager.importSignedConfiguration(byteArrayOf(1)) + manager.reviewStudy() + manager.acceptConsent() + manager.completeAccessSetup() + manager.start() + runCurrent() + fixture.store.saveFailure = IllegalStateException("metadata unavailable") + + fixture.access.granted -= AccessKind.NOTIFICATIONS + manager.reconcileAccess() + runCurrent() + + // Durable metadata still says RUNNING because that write failed, but every live collection + // surface must already be closed. Recovery will retry the transition before activation. + assertEquals(ExperimentState.RUNNING, manager.snapshot.value.runtime.metadata?.state) + assertEquals("PAUSE_PERSISTENCE_FAILED", manager.snapshot.value.incidentCode) + assertEquals("PAUSE_PERSISTENCE_FAILED", manager.snapshot.value.runtime.incidentCode) + assertEquals(1, fixture.host.stopCount) + assertEquals(1, fixture.work.cancelInterventionCount) + assertEquals(1, fixture.collector.pauseCount) + assertTrue(runCatching { fixture.collector.emit(1) }.isFailure) + assertEquals(SafetyPauseReason.REQUIRED_ACCESS_MISSING, fixture.pauseStore.pendingReason) + assertEquals( + SafetyPauseStatus.Pending(SafetyPauseReason.REQUIRED_ACCESS_MISSING), + manager.snapshot.value.safetyPauseStatus, + ) + assertEquals(1, fixture.work.safetyPauseScheduleCount) + + fixture.store.saveFailure = null + fixture.access.granted += AccessKind.NOTIFICATIONS + assertTrue( + manager.retrySafetyPause("session-test", SafetyPauseReason.REQUIRED_ACCESS_MISSING), + ) + runCurrent() + assertEquals(ExperimentState.PAUSED, manager.snapshot.value.runtime.metadata?.state) + assertEquals(TransitionReason.REQUIRED_ACCESS_MISSING, manager.snapshot.value.runtime.metadata + ?.transitions?.last()?.reason) + assertEquals("REQUIRED_ACCESS_MISSING", manager.snapshot.value.incidentCode) + assertNull(fixture.pauseStore.pendingReason) + assertNull(manager.snapshot.value.safetyPauseStatus) + assertEquals(0, fixture.collector.resumeCount) + } + + @Test + fun durablePauseMarkerForcesPausedRecoveryEvenAfterAccessReturns() = runTest { + val configuration = configuration() + val pauseStore = FakeSafetyPauseStore( + pendingReason = SafetyPauseReason.REQUIRED_ACCESS_MISSING, + ) + val running = startedMetadata(configuration) + val fixture = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = running, + pauseStore = pauseStore, + ) + + fixture.manager.initialize() + runCurrent() + + assertEquals(ExperimentState.PAUSED, fixture.manager.snapshot.value.runtime.metadata?.state) + assertEquals( + TransitionReason.REQUIRED_ACCESS_MISSING, + fixture.manager.snapshot.value.runtime.metadata?.transitions?.last()?.reason, + ) + assertEquals(0, fixture.host.startCount) + assertEquals(0, fixture.collector.startCount) + assertNull(pauseStore.pendingReason) + assertNull(fixture.manager.snapshot.value.safetyPauseStatus) + } + + @Test + fun safetyPauseRetryCompletesCollectorTeardownFailure() = runTest { + val fixture = fixture(configuration()) + val manager = fixture.manager + manager.initialize() + manager.importSignedConfiguration(byteArrayOf(1)) + manager.reviewStudy() + manager.acceptConsent() + manager.completeAccessSetup() + manager.start() + runCurrent() + fixture.collector.failNextPause = true + + fixture.access.granted -= AccessKind.NOTIFICATIONS + manager.reconcileAccess() + runCurrent() + + assertEquals(ExperimentState.PAUSED, manager.snapshot.value.runtime.metadata?.state) + assertEquals("COLLECTOR_PAUSE_FAILED", manager.snapshot.value.incidentCode) + assertEquals(SafetyPauseReason.REQUIRED_ACCESS_MISSING, fixture.pauseStore.pendingReason) + assertEquals(1, fixture.work.safetyPauseScheduleCount) + assertTrue(runCatching { fixture.collector.emit(1) }.isFailure) + + assertTrue( + manager.retrySafetyPause("session-test", SafetyPauseReason.REQUIRED_ACCESS_MISSING), + ) + runCurrent() + assertEquals(2, fixture.collector.pauseCount) + assertNull(fixture.pauseStore.pendingReason) + assertEquals("REQUIRED_ACCESS_MISSING", manager.snapshot.value.incidentCode) + } + + @Test + fun participantPauseCollectorFailureCompletesTypedTeardownProtocolBeforeReturning() = runTest { + val fixture = fixture(configuration()) + val manager = fixture.manager + manager.initialize() + manager.importSignedConfiguration(byteArrayOf(1)) + manager.reviewStudy() + manager.acceptConsent() + manager.completeAccessSetup() + manager.start() + runCurrent() + fixture.collector.failNextPause = true + + assertEquals( + CommandResult.Failed("COLLECTION_TEARDOWN_FAILED"), + manager.pause(), + ) + runCurrent() + + assertEquals(ExperimentState.PAUSED, manager.snapshot.value.runtime.metadata?.state) + assertEquals( + TransitionReason.COLLECTION_TEARDOWN_FAILURE, + manager.snapshot.value.runtime.metadata?.transitions?.last()?.reason, + ) + assertEquals("COLLECTION_TEARDOWN_FAILED", manager.snapshot.value.incidentCode) + assertNull(manager.snapshot.value.runtime.pendingSafetyPauseReason) + assertNull(fixture.pauseStore.pendingReason) + assertEquals(2, fixture.collector.pauseCount) + assertEquals(1, fixture.host.stopCount) + assertTrue(runCatching { fixture.collector.emit(1) }.isFailure) + } + + @Test + fun typedWorkerReasonSurvivesMarkerAndMetadataWriteFailures() = runTest { + val locationAccess = setOf( + AccessKind.FINE_LOCATION, + AccessKind.LOCATION_SERVICES, + AccessKind.BACKGROUND_LOCATION, + ) + val location = locationConfiguration(required = false) + val lifecycle = AppLifecycleConfiguration(required = true) + val fixture = fixture( + configuration = configuration(collectors = listOf(lifecycle, location)), + grantedAccess = setOf(AccessKind.NOTIFICATIONS), + collectorAccessKindsById = mapOf(location.id to locationAccess), + ) + val manager = fixture.manager + manager.initialize() + manager.importSignedConfiguration(byteArrayOf(1)) + manager.reviewStudy() + manager.acceptConsent() + manager.completeAccessSetup() + manager.start() + runCurrent() + fixture.pauseStore.markFailure = IllegalStateException("marker unavailable") + fixture.store.saveFailure = IllegalStateException("metadata unavailable") + fixture.host.locationStartFailure = IllegalStateException("location type rejected") + fixture.host.nonLocationStartFailure = IllegalStateException("fallback rejected") + + fixture.access.granted += locationAccess + manager.reconcileAccess() + runCurrent() + + assertEquals(ExperimentState.RUNNING, manager.snapshot.value.runtime.metadata?.state) + assertNull(fixture.pauseStore.pendingReason) + assertEquals( + SafetyPauseStatus.Pending(SafetyPauseReason.COLLECTION_HOST_FAILURE), + manager.snapshot.value.safetyPauseStatus, + ) + assertEquals(SafetyPauseReason.COLLECTION_HOST_FAILURE, fixture.work.scheduledSafetyPauseReason) + fixture.collectors.values.forEach { collector -> + assertTrue(runCatching { collector.emit(1) }.isFailure) + } + + fixture.store.saveFailure = null + assertTrue( + manager.retrySafetyPause("session-test", SafetyPauseReason.COLLECTION_HOST_FAILURE), + ) + runCurrent() + + val metadata = manager.snapshot.value.runtime.metadata + assertEquals(ExperimentState.PAUSED, metadata?.state) + assertEquals(TransitionReason.COLLECTION_HOST_FAILURE, metadata?.transitions?.last()?.reason) + assertNull(manager.snapshot.value.safetyPauseStatus) + assertEquals("COLLECTION_HOST_FAILED", manager.snapshot.value.incidentCode) + } + + @Test + fun storageFailureUsesTypedWorkerWhenMarkerAndPauseMetadataWritesFailAndRecoversClosed() = runTest { + val configuration = configuration() + val running = fixture(configuration) + running.manager.initialize() + running.manager.importSignedConfiguration(byteArrayOf(1)) + running.manager.reviewStudy() + running.manager.acceptConsent() + running.manager.completeAccessSetup() + running.manager.start() + runCurrent() + running.store.appendFailure = IllegalStateException("event storage unavailable") + running.store.saveFailure = IllegalStateException("pause metadata unavailable") + running.pauseStore.markFailure = IllegalStateException("marker unavailable") + + running.collector.emit(1) + runCurrent() + + val failed = running.manager.snapshot.value + assertEquals(ExperimentState.RUNNING, failed.runtime.metadata?.state) + assertEquals("PAUSE_PERSISTENCE_FAILED", failed.incidentCode) + assertNull(failed.runtime.pendingSafetyPauseReason) + assertEquals( + SafetyPauseStatus.Pending(SafetyPauseReason.STORAGE_FAILURE), + failed.safetyPauseStatus, + ) + assertEquals(SafetyPauseReason.STORAGE_FAILURE, running.work.scheduledSafetyPauseReason) + assertTrue(running.work.safetyPauseScheduleCount >= 1) + assertEquals(1, running.host.stopCount) + assertEquals(1, running.collector.pauseCount) + assertTrue(runCatching { running.collector.emit(1) }.isFailure) + + val recovered = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = startedMetadata(configuration), + ) + recovered.work.activeSafetyPauseReasons += SafetyPauseReason.STORAGE_FAILURE + + recovered.manager.initialize() + runCurrent() + + val recoveredMetadata = recovered.manager.snapshot.value.runtime.metadata + assertEquals(ExperimentState.PAUSED, recoveredMetadata?.state) + assertEquals(TransitionReason.STORAGE_FAILURE, recoveredMetadata?.transitions?.last()?.reason) + assertEquals("STORAGE_WRITE_FAILED", recovered.manager.snapshot.value.incidentCode) + assertEquals(0, recovered.host.startCount) + assertEquals(0, recovered.collector.startCount) + assertNull(recovered.manager.snapshot.value.runtime.pendingSafetyPauseReason) + assertTrue(recovered.work.activeSafetyPauseReasons.isEmpty()) + } + + @Test + fun storageFailureSignalRemainsUntilTypedRetryPersistenceIsConfirmed() = runTest { + val fixture = fixture(configuration()) + fixture.manager.initialize() + fixture.manager.importSignedConfiguration(byteArrayOf(1)) + fixture.manager.reviewStudy() + fixture.manager.acceptConsent() + fixture.manager.completeAccessSetup() + fixture.manager.start() + runCurrent() + fixture.store.appendFailure = IllegalStateException("event storage unavailable") + fixture.store.saveFailure = IllegalStateException("pause metadata unavailable") + fixture.pauseStore.markFailure = IllegalStateException("marker unavailable") + fixture.work.safetyPauseScheduleFailure = IllegalStateException("work database unavailable") + + fixture.collector.emit(1) + runCurrent() + + assertEquals( + SafetyPauseReason.STORAGE_FAILURE, + fixture.manager.snapshot.value.runtime.pendingSafetyPauseReason, + ) + assertEquals("SAFETY_PAUSE_RETRY_SCHEDULING_FAILED", fixture.manager.snapshot.value.incidentCode) + assertTrue(fixture.work.activeSafetyPauseReasons.isEmpty()) + assertTrue(runCatching { fixture.collector.emit(1) }.isFailure) + } + + @Test + fun occurrencePlanningStorageFailurePausesWithStorageReasonAndCannotRestartAfterProcessDeath() = runTest { + val intervention = InterventionConfiguration( + id = "notice-one", + action = NotificationAction("Study check-in", "Check in"), + triggers = listOf( + InterventionTrigger( + "after-minute", + OneTimeSchedule(1, RelativeClock.CALENDAR_TIME), + availabilityMinutes = 60, + ), + ), + ) + val configuration = configuration(interventions = listOf(intervention)) + val running = fixture(configuration) + running.manager.initialize() + running.manager.importSignedConfiguration(byteArrayOf(1)) + running.manager.reviewStudy() + running.manager.acceptConsent() + running.manager.completeAccessSetup() + running.store.appendFailure = IllegalStateException("occurrence storage unavailable") + + assertEquals(CommandResult.Failed("STORAGE_WRITE_FAILED"), running.manager.start()) + runCurrent() + + val paused = running.manager.snapshot.value.runtime.metadata + assertEquals(ExperimentState.PAUSED, paused?.state) + assertEquals(TransitionReason.STORAGE_FAILURE, paused?.transitions?.last()?.reason) + assertEquals(1, running.host.stopCount) + assertEquals(1, running.collector.pauseCount) + assertNull(running.manager.snapshot.value.runtime.pendingSafetyPauseReason) + + val recovered = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = paused, + ) + recovered.manager.initialize() + runCurrent() + + assertEquals(ExperimentState.PAUSED, recovered.manager.snapshot.value.runtime.metadata?.state) + assertEquals( + TransitionReason.STORAGE_FAILURE, + recovered.manager.snapshot.value.runtime.metadata?.transitions?.last()?.reason, + ) + assertEquals(0, recovered.host.startCount) + assertEquals(0, recovered.collector.startCount) + } + + @Test + fun safetyWorkerOnAnAlreadyPausedStudyPreservesTheParticipantAuditBoundary() = runTest { + val configuration = configuration() + val participantPause = ExperimentTransition( + from = ExperimentState.RUNNING, + to = ExperimentState.PAUSED, + reason = TransitionReason.PARTICIPANT_PAUSED, + time = ResearchTime(100, 100, "boot-before-crash"), + ) + val fixture = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = StudyMetadata.initial( + configuration.experimentId, + configuration.configurationId, + ).copy( + state = ExperimentState.PAUSED, + transitions = listOf( + ExperimentTransition( + from = ExperimentState.READY, + to = ExperimentState.RUNNING, + reason = TransitionReason.PARTICIPANT_STARTED, + time = ResearchTime(99, 99, "boot-before-crash"), + ), + participantPause, + ), + ), + ) + fixture.work.activeSafetyPauseReasons += SafetyPauseReason.STORAGE_FAILURE + + fixture.manager.initialize() + runCurrent() + + val metadata = fixture.manager.snapshot.value.runtime.metadata + assertEquals(ExperimentState.PAUSED, metadata?.state) + assertEquals(TransitionReason.PARTICIPANT_PAUSED, metadata?.transitions?.last()?.reason) + assertEquals(participantPause.time, metadata?.transitions?.last()?.time) + assertEquals(0, fixture.host.startCount) + assertEquals(0, fixture.collector.startCount) + assertTrue(fixture.work.activeSafetyPauseReasons.isEmpty()) + } + + @Test + fun typedWorkerOnReadyMetadataRewritesTheSafeBoundaryBeforeClearingItsWitness() = runTest { + val configuration = configuration() + val prepared = fixture(configuration) + prepared.manager.initialize() + prepared.manager.importSignedConfiguration(byteArrayOf(1)) + prepared.manager.reviewStudy() + prepared.manager.acceptConsent() + prepared.manager.completeAccessSetup() + val ready = requireNotNull(prepared.store.metadata) + assertEquals(ExperimentState.READY, ready.state) + + val recovered = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = ready, + ) + recovered.work.activeSafetyPauseReasons += SafetyPauseReason.WORK_SCHEDULING_FAILURE + + recovered.manager.initialize() + runCurrent() + + assertEquals(ExperimentState.READY, recovered.manager.snapshot.value.runtime.metadata?.state) + assertEquals(1, recovered.store.saveCount) + assertNull(recovered.manager.snapshot.value.runtime.pendingSafetyPauseReason) + assertTrue(recovered.work.activeSafetyPauseReasons.isEmpty()) + assertEquals(0, recovered.host.startCount) + assertEquals(0, recovered.collector.startCount) + } + + @Test + fun runningCommandMetadataFailuresAllCreateTypedStorageSafetyWitnesses() = runTest { + val commands = listOf CommandResult>>( + "pause" to { it.manager.pause() }, + "finish" to { it.manager.finish() }, + "duration" to { it.completeDurationCommand() }, + "withdraw" to { it.manager.withdraw() }, + ) + commands.forEach { (name, command) -> + val fixture = fixture(configuration()) + fixture.manager.initialize() + fixture.manager.importSignedConfiguration(byteArrayOf(1)) + fixture.manager.reviewStudy() + fixture.manager.acceptConsent() + fixture.manager.completeAccessSetup() + fixture.manager.start() + runCurrent() + fixture.store.saveFailure = IllegalStateException("$name metadata unavailable") + + assertEquals(CommandResult.Failed("STORAGE_WRITE_FAILED"), command(fixture)) + runCurrent() + + assertEquals(ExperimentState.RUNNING, fixture.manager.snapshot.value.runtime.metadata?.state) + assertEquals( + SafetyPauseStatus.Pending(SafetyPauseReason.STORAGE_FAILURE), + fixture.manager.snapshot.value.safetyPauseStatus, + ) + assertEquals(SafetyPauseReason.STORAGE_FAILURE, fixture.work.scheduledSafetyPauseReason) + assertEquals(1, fixture.host.stopCount) + assertEquals(1, fixture.collector.startCount) + assertTrue(runCatching { fixture.collector.emit(1) }.isFailure) + } + } + + @Test + fun resumeSideEffectFailureReturnsOnlyAfterTypedStoragePauseIsDurable() = runTest { + val fixture = fixture(configuration()) + fixture.manager.initialize() + fixture.manager.importSignedConfiguration(byteArrayOf(1)) + fixture.manager.reviewStudy() + fixture.manager.acceptConsent() + fixture.manager.completeAccessSetup() + fixture.manager.start() + fixture.manager.pause() + fixture.work.replacementFailure = IllegalStateException("work database unavailable") + + assertEquals(CommandResult.Failed("WORK_SCHEDULING_FAILED"), fixture.manager.resume()) + runCurrent() + + val metadata = fixture.manager.snapshot.value.runtime.metadata + assertEquals(ExperimentState.PAUSED, metadata?.state) + assertEquals(TransitionReason.WORK_SCHEDULING_FAILURE, metadata?.transitions?.last()?.reason) + assertEquals(1, fixture.collector.resumeCount) + assertEquals(2, fixture.collector.pauseCount) + assertEquals(2, fixture.host.stopCount) + assertNull(fixture.manager.snapshot.value.runtime.pendingSafetyPauseReason) + assertTrue(runCatching { fixture.collector.emit(1) }.isFailure) + } + + @Test + fun recoveryMarkerFailureFallsBackToTypedWorkBeforeAccessCanLaterReopen() = runTest { + val configuration = configuration() + val runningMetadata = startedMetadata(configuration) + val first = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = runningMetadata, + grantedAccess = emptySet(), + ) + first.pauseStore.markFailure = IllegalStateException("marker unavailable") + first.store.saveFailure = IllegalStateException("pause metadata unavailable") + + first.manager.initialize() + runCurrent() + + assertTrue(first.manager.snapshot.value.recoveryBlocked) + assertEquals(SafetyPauseReason.REQUIRED_ACCESS_MISSING, first.work.scheduledSafetyPauseReason) + assertEquals(0, first.host.startCount) + assertEquals(0, first.collector.startCount) + + val recovered = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = runningMetadata, + grantedAccess = setOf(AccessKind.NOTIFICATIONS), + ) + recovered.work.activeSafetyPauseReasons += SafetyPauseReason.REQUIRED_ACCESS_MISSING + + recovered.manager.initialize() + runCurrent() + + assertEquals(ExperimentState.PAUSED, recovered.manager.snapshot.value.runtime.metadata?.state) + assertEquals( + TransitionReason.REQUIRED_ACCESS_MISSING, + recovered.manager.snapshot.value.runtime.metadata?.transitions?.last()?.reason, + ) + assertEquals(0, recovered.host.startCount) + assertEquals(0, recovered.collector.startCount) + } + + @Test + fun activeTypedWorkerAloneForcesFailClosedProcessDeathRecovery() = runTest { + val configuration = configuration() + val running = startedMetadata(configuration) + val fixture = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = running, + ) + fixture.work.activeSafetyPauseReasons += SafetyPauseReason.COLLECTION_HOST_FAILURE + + fixture.manager.initialize() + runCurrent() + + val metadata = fixture.manager.snapshot.value.runtime.metadata + assertEquals(ExperimentState.PAUSED, metadata?.state) + assertEquals(TransitionReason.COLLECTION_HOST_FAILURE, metadata?.transitions?.last()?.reason) + assertEquals("COLLECTION_HOST_FAILED", fixture.manager.snapshot.value.incidentCode) + assertEquals(0, fixture.host.startCount) + assertEquals(0, fixture.collector.startCount) + assertNull(fixture.manager.snapshot.value.safetyPauseStatus) + assertTrue(fixture.work.activeSafetyPauseReasons.isEmpty()) + } + + @Test + fun conflictingMarkerAndWorkerReasonsKeepRecoveryClosed() = runTest { + val configuration = configuration() + val running = startedMetadata(configuration) + val pauseStore = FakeSafetyPauseStore(SafetyPauseReason.REQUIRED_ACCESS_MISSING) + val fixture = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = running, + pauseStore = pauseStore, + ) + fixture.work.activeSafetyPauseReasons += SafetyPauseReason.COLLECTION_HOST_FAILURE + + fixture.manager.initialize() + runCurrent() + + assertNull(fixture.manager.snapshot.value.configuration) + assertEquals("STUDY_RECOVERY_FAILED", fixture.manager.snapshot.value.incidentCode) + assertEquals( + SafetyPauseStatus.Pending(SafetyPauseReason.REQUIRED_ACCESS_MISSING), + fixture.manager.snapshot.value.safetyPauseStatus, + ) + assertEquals(0, fixture.host.startCount) + assertEquals(0, fixture.collector.startCount) + } + + @Test + fun multipleActiveWorkerReasonsKeepRecoveryClosed() = runTest { + val configuration = configuration() + val fixture = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = startedMetadata(configuration), + ) + fixture.work.activeSafetyPauseReasons += SafetyPauseReason.entries + + fixture.manager.initialize() + runCurrent() + + val blocked = fixture.manager.snapshot.value + assertNull(blocked.configuration) + assertTrue(blocked.recoveryBlocked) + assertEquals("STUDY_RECOVERY_FAILED", blocked.incidentCode) + assertEquals(0, fixture.host.startCount) + assertEquals(0, fixture.collector.startCount) + + val importFailure = runCatching { + fixture.manager.importSignedConfiguration(byteArrayOf(2)) + }.exceptionOrNull() + + assertTrue(importFailure is IllegalStateException) + assertTrue(fixture.manager.snapshot.value.recoveryBlocked) + assertEquals("STUDY_RECOVERY_FAILED", fixture.manager.snapshot.value.incidentCode) + assertNull(fixture.manager.snapshot.value.configuration) + } + + @Test + fun unreadableTypedMarkerKeepsRecoveryClosed() = runTest { + val configuration = configuration() + val pauseStore = FakeSafetyPauseStore().also { + it.readFailure = IllegalStateException("unknown marker reason") + } + val fixture = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = startedMetadata(configuration), + pauseStore = pauseStore, + ) + + fixture.manager.initialize() + runCurrent() + + assertNull(fixture.manager.snapshot.value.configuration) + assertEquals("STUDY_RECOVERY_FAILED", fixture.manager.snapshot.value.incidentCode) + assertEquals(SafetyPauseStatus.MarkerUnreadable, fixture.manager.snapshot.value.safetyPauseStatus) + assertEquals(0, fixture.host.startCount) + assertEquals(0, fixture.collector.startCount) + } + + @Test + fun optionalCollectorAccessIsPausedAndResumedWithoutStoppingTheStudy() = runTest { + val accessKind = AccessKind.ACCELEROMETER_HARDWARE + val fixture = fixture( + configuration(collectors = listOf(AppLifecycleConfiguration(required = false))), + grantedAccess = setOf(AccessKind.NOTIFICATIONS, accessKind), + collectorAccessKinds = setOf(accessKind), + ) + val manager = fixture.manager + manager.initialize() + manager.importSignedConfiguration(byteArrayOf(1)) + manager.reviewStudy() + manager.acceptConsent() + manager.completeAccessSetup() + manager.start() + runCurrent() + + fixture.access.granted -= accessKind + manager.reconcileAccess() + runCurrent() + + assertEquals(ExperimentState.RUNNING, manager.snapshot.value.runtime.metadata?.state) + assertEquals(0, fixture.host.stopCount) + assertEquals(1, fixture.collector.pauseCount) + assertEquals( + CollectorStatus.BLOCKED_ACCESS, + manager.snapshot.value.runtime.collectorHealth.getValue(AppLifecycleConfiguration.ID).status, + ) + + fixture.access.granted += accessKind + manager.reconcileAccess() + runCurrent() + + assertEquals(ExperimentState.RUNNING, manager.snapshot.value.runtime.metadata?.state) + assertEquals(1, fixture.collector.resumeCount) + assertEquals( + CollectorStatus.ACTIVE, + manager.snapshot.value.runtime.collectorHealth.getValue(AppLifecycleConfiguration.ID).status, + ) + } + + @Test + fun optionalLocationPromotesForegroundServiceBeforeStartingCollector() = runTest { + val locationAccess = setOf( + AccessKind.FINE_LOCATION, + AccessKind.LOCATION_SERVICES, + AccessKind.BACKGROUND_LOCATION, + ) + val location = LocationConfiguration( + required = false, + intervalMillis = 60_000, + minimumIntervalMillis = 30_000, + maximumBatchDelayMillis = 0, + minimumDisplacementMillimeters = 0, + priority = LocationPriority.BALANCED, + ) + val fixture = fixture( + configuration = configuration(collectors = listOf(location)), + grantedAccess = setOf(AccessKind.NOTIFICATIONS), + collectorAccessKinds = locationAccess, + ) + val manager = fixture.manager + manager.initialize() + manager.importSignedConfiguration(byteArrayOf(1)) + manager.reviewStudy() + manager.acceptConsent() + manager.completeAccessSetup() + manager.start() + runCurrent() + + assertEquals(listOf(false), fixture.host.locationStarts) + assertEquals(0, fixture.collector.startCount) + + fixture.access.granted += locationAccess + manager.reconcileAccess() + runCurrent() + + assertEquals(listOf(false, true), fixture.host.locationStarts) + assertEquals(1, fixture.collector.startCount) + assertTrue( + fixture.lifecycleEvents.indexOf("host:true") < + fixture.lifecycleEvents.indexOf("collector:start"), + ) + } + + @Test + fun failedLocationPromotionWithAcknowledgedFallbackKeepsNonLocationCollectionRunning() = runTest { + val locationAccess = setOf( + AccessKind.FINE_LOCATION, + AccessKind.LOCATION_SERVICES, + AccessKind.BACKGROUND_LOCATION, + ) + val location = locationConfiguration(required = false) + val lifecycle = AppLifecycleConfiguration(required = true) + val fixture = fixture( + configuration = configuration(collectors = listOf(lifecycle, location)), + grantedAccess = setOf(AccessKind.NOTIFICATIONS), + collectorAccessKindsById = mapOf(location.id to locationAccess), + ) + val manager = fixture.manager + manager.initialize() + manager.importSignedConfiguration(byteArrayOf(1)) + manager.reviewStudy() + manager.acceptConsent() + manager.completeAccessSetup() + manager.start() + runCurrent() + fixture.host.locationStartFailure = IllegalStateException("location type rejected") + + fixture.access.granted += locationAccess + manager.reconcileAccess() + runCurrent() + + assertEquals(ExperimentState.RUNNING, manager.snapshot.value.runtime.metadata?.state) + assertEquals(listOf(false, true, false), fixture.host.locationStarts) + assertEquals("COLLECTION_HOST_FAILED", manager.snapshot.value.incidentCode) + assertNull(manager.snapshot.value.safetyPauseStatus) + fixture.collectors.getValue(lifecycle.id).emit(1) + runCurrent() + assertEquals(1L, manager.snapshot.value.runtime.metadata?.eventCount) + assertTrue(runCatching { fixture.collectors.getValue(location.id).emit(1) }.isFailure) + } + + @Test + fun failedLocationPromotionAndFallbackDurablyPauseAndCloseEveryCollector() = runTest { + val locationAccess = setOf( + AccessKind.FINE_LOCATION, + AccessKind.LOCATION_SERVICES, + AccessKind.BACKGROUND_LOCATION, + ) + val location = locationConfiguration(required = false) + val lifecycle = AppLifecycleConfiguration(required = true) + val fixture = fixture( + configuration = configuration(collectors = listOf(lifecycle, location)), + grantedAccess = setOf(AccessKind.NOTIFICATIONS), + collectorAccessKindsById = mapOf(location.id to locationAccess), + ) + val manager = fixture.manager + manager.initialize() + manager.importSignedConfiguration(byteArrayOf(1)) + manager.reviewStudy() + manager.acceptConsent() + manager.completeAccessSetup() + manager.start() + runCurrent() + fixture.host.locationStartFailure = IllegalStateException("location type rejected") + fixture.host.nonLocationStartFailure = IllegalStateException("fallback rejected") + + fixture.access.granted += locationAccess + manager.reconcileAccess() + runCurrent() + + val metadata = manager.snapshot.value.runtime.metadata + assertEquals(ExperimentState.PAUSED, metadata?.state) + assertEquals(TransitionReason.COLLECTION_HOST_FAILURE, metadata?.transitions?.last()?.reason) + assertEquals("COLLECTION_HOST_FAILED", manager.snapshot.value.incidentCode) + assertEquals(listOf(false, true, false), fixture.host.locationStarts) + assertEquals(1, fixture.host.stopCount) + assertNull(manager.snapshot.value.safetyPauseStatus) + fixture.collectors.values.forEach { collector -> + assertTrue(runCatching { collector.emit(1) }.isFailure) + } + assertEquals(0L, metadata?.eventCount) + } + + @Test + fun failedLocationDemotionDurablyPausesAndClosesEveryCollector() = runTest { + val locationAccess = setOf( + AccessKind.FINE_LOCATION, + AccessKind.LOCATION_SERVICES, + AccessKind.BACKGROUND_LOCATION, + ) + val location = locationConfiguration(required = false) + val lifecycle = AppLifecycleConfiguration(required = true) + val fixture = fixture( + configuration = configuration(collectors = listOf(lifecycle, location)), + grantedAccess = setOf(AccessKind.NOTIFICATIONS) + locationAccess, + collectorAccessKindsById = mapOf(location.id to locationAccess), + ) + val manager = fixture.manager + manager.initialize() + manager.importSignedConfiguration(byteArrayOf(1)) + manager.reviewStudy() + manager.acceptConsent() + manager.completeAccessSetup() + manager.start() + runCurrent() + fixture.host.nonLocationStartFailure = IllegalStateException("demotion rejected") + + fixture.access.granted -= locationAccess + manager.reconcileAccess() + runCurrent() + + val metadata = manager.snapshot.value.runtime.metadata + assertEquals(ExperimentState.PAUSED, metadata?.state) + assertEquals(TransitionReason.COLLECTION_HOST_FAILURE, metadata?.transitions?.last()?.reason) + assertEquals("COLLECTION_HOST_FAILED", manager.snapshot.value.incidentCode) + assertEquals(listOf(true, false), fixture.host.locationStarts) + assertEquals(1, fixture.host.stopCount) + fixture.collectors.values.forEach { collector -> + assertTrue(runCatching { collector.emit(1) }.isFailure) + } + assertEquals(0L, metadata?.eventCount) + } + + @Test + fun accessInspectionFailurePausesARunningStudyBeforePropagatingTheFailure() = runTest { + val fixture = fixture(configuration()) + val manager = fixture.manager + manager.initialize() + manager.importSignedConfiguration(byteArrayOf(1)) + manager.reviewStudy() + manager.acceptConsent() + manager.completeAccessSetup() + manager.start() + runCurrent() + fixture.access.failure = IllegalStateException("platform inspection failed") + + val failure = runCatching { manager.reconcileAccess() }.exceptionOrNull() + runCurrent() + + assertEquals("platform inspection failed", failure?.message) + assertEquals(ExperimentState.PAUSED, manager.snapshot.value.runtime.metadata?.state) + assertEquals("ACCESS_INSPECTION_FAILED", manager.snapshot.value.incidentCode) + assertEquals(1, fixture.host.stopCount) + assertEquals(1, fixture.collector.pauseCount) + } + + @Test + fun recoveryOfRunningStudyRestartsCollectionHostOnce() = runTest { + val configuration = configuration() + val fixture = fixture( + configuration, + activeEnvelope = byteArrayOf(9), + initialMetadata = startedMetadata(configuration), + ) + + fixture.manager.initialize() + runCurrent() + + assertEquals(ExperimentState.RUNNING, fixture.manager.snapshot.value.runtime.metadata?.state) + assertEquals(1, fixture.host.startCount) + assertEquals(1, fixture.collector.startCount) + assertTrue( + fixture.lifecycleEvents.indexOf("host:false") < + fixture.lifecycleEvents.indexOf("collector:start"), + ) + assertTrue(fixture.manager.reconcileRedeliveredCollectionHost()) + assertEquals(2, fixture.host.startCount) + } + + @Test + fun rebootRecoveryCannotExtendTheAbsoluteDeadlineAndNeverReopensCollection() = runTest { + val configuration = configuration() + val fixture = fixture( + configuration, + activeEnvelope = byteArrayOf(9), + initialMetadata = startedMetadata(configuration), + // Wall time still follows the participant start. It is nevertheless untrusted because + // this boot has no monotonic relationship with the boot that established the start. + recoveredBootSessionId = "boot-after-recovery", + ) + + fixture.manager.initialize() + runCurrent() + + val recovered = fixture.manager.snapshot.value.runtime.metadata + assertEquals(ExperimentState.PAUSED, recovered?.state) + assertEquals(TransitionReason.WORK_SCHEDULING_FAILURE, recovered?.transitions?.last()?.reason) + assertEquals("WORK_SCHEDULING_FAILED", fixture.manager.snapshot.value.incidentCode) + assertEquals(0, fixture.host.startCount) + assertEquals(0, fixture.collector.startCount) + assertEquals(0, fixture.work.scheduleCount) + + assertEquals( + CommandResult.Failed("WORK_SCHEDULING_FAILED"), + fixture.manager.resume(), + ) + assertEquals(ExperimentState.PAUSED, fixture.manager.snapshot.value.runtime.metadata?.state) + assertEquals(0, fixture.host.startCount) + assertEquals(0, fixture.collector.resumeCount) + } + + @Test + fun rebootRecoveryOfParticipantPausedStudyPreservesPauseAndRejectsResume() = runTest { + val configuration = configuration() + val participantPaused = startedMetadata( + configuration = configuration, + state = ExperimentState.PAUSED, + ) + val participantPause = participantPaused.transitions.last() + val fixture = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(9), + initialMetadata = participantPaused, + recoveredBootSessionId = "boot-after-recovery", ) + + fixture.manager.initialize() runCurrent() - assertEquals(ExperimentState.ACCESS_SETUP, fixture.manager.snapshot.value.runtime.metadata?.state) - assertTrue( - fixture.manager.snapshot.value.access.single { it.requirement.kind == AccessKind.NOTIFICATIONS } - .requirement.required, + + val recovered = fixture.manager.snapshot.value.runtime.metadata + assertEquals(ExperimentState.PAUSED, recovered?.state) + assertEquals(participantPaused.transitions, recovered?.transitions) + assertEquals(TransitionReason.PARTICIPANT_PAUSED, recovered?.transitions?.last()?.reason) + assertEquals(participantPause.time, recovered?.transitions?.last()?.time) + assertEquals("WORK_SCHEDULING_FAILED", fixture.manager.snapshot.value.incidentCode) + assertNull(fixture.manager.snapshot.value.runtime.pendingSafetyPauseReason) + assertNull(fixture.manager.snapshot.value.safetyPauseStatus) + assertEquals(0, fixture.host.startCount) + assertEquals(0, fixture.collector.startCount) + assertEquals(0, fixture.collector.pauseCount) + assertEquals(0, fixture.work.scheduleCount) + + assertEquals( + CommandResult.Failed("WORK_SCHEDULING_FAILED"), + fixture.manager.resume(), ) + assertEquals(ExperimentState.PAUSED, fixture.manager.snapshot.value.runtime.metadata?.state) + assertEquals(participantPaused.transitions, fixture.manager.snapshot.value.runtime.metadata?.transitions) + assertEquals(0, fixture.host.startCount) + assertEquals(0, fixture.collector.resumeCount) + assertEquals(0, fixture.work.scheduleCount) } @Test - fun recoveryOfRunningStudyRestartsCollectionHostOnce() = runTest { + fun recoveryWithMissingRequiredAccessPausesBeforeCollectionRestarts() = runTest { val configuration = configuration() val fixture = fixture( configuration, activeEnvelope = byteArrayOf(9), - initialMetadata = StudyMetadata.initial(configuration.experimentId, configuration.configurationId) - .copy(state = ExperimentState.RUNNING), + initialMetadata = startedMetadata(configuration), + grantedAccess = emptySet(), ) fixture.manager.initialize() runCurrent() - assertEquals(ExperimentState.RUNNING, fixture.manager.snapshot.value.runtime.metadata?.state) - assertEquals(1, fixture.host.startCount) - assertEquals(1, fixture.collector.startCount) + val metadata = fixture.manager.snapshot.value.runtime.metadata + assertEquals(ExperimentState.PAUSED, metadata?.state) + assertEquals(TransitionReason.REQUIRED_ACCESS_MISSING, metadata?.transitions?.last()?.reason) + assertEquals(0, fixture.host.startCount) + assertEquals(0, fixture.collector.startCount) + assertFalse(fixture.manager.reconcileRedeliveredCollectionHost()) } @Test - fun bootOrTimezoneReconciliationRestoresPostedAndOpenedExpiryWork() = runTest { + fun sameBootTimeOrTimezoneReconciliationRestoresPostedAndOpenedExpiryWork() = runTest { val notice = InterventionConfiguration( "notice-one", NotificationAction("Study check-in", "Check in"), @@ -309,8 +1496,8 @@ class StudySessionManagerTest { ).copy(openedAt = ResearchTime(200, 200, "boot-before-recovery")) val posting = occurrence("d".repeat(64), OccurrenceState.POSTING) val expired = occurrence("e".repeat(64), OccurrenceState.EXPIRED) - val metadata = StudyMetadata.initial(configuration.experimentId, configuration.configurationId).copy( - state = ExperimentState.RUNNING, + val metadata = startedMetadata( + configuration, occurrences = listOf(posted, openedNotice, openedSurvey, posting, expired).associateBy { it.occurrenceId }, ) val fixture = fixture( @@ -320,12 +1507,12 @@ class StudySessionManagerTest { ) fixture.manager.initialize() - fixture.manager.rescheduleInterventions(recoverStalePosting = true) + fixture.manager.reconcileScheduledWork(recoverStalePosting = true) - assertTrue(fixture.work.replacementDeliveries.isEmpty()) - assertEquals( - setOf(posted.occurrenceId, openedSurvey.occurrenceId, posting.occurrenceId), - fixture.work.replacementExpiries.mapTo(mutableSetOf()) { it.occurrenceId }, + assertTrue( + fixture.work.replacementExpiries.mapTo(mutableSetOf()) { it.occurrenceId }.containsAll( + setOf(posted.occurrenceId, openedSurvey.occurrenceId, posting.occurrenceId), + ), ) assertEquals( setOf(openedNotice.occurrenceId, openedSurvey.occurrenceId, posting.occurrenceId, expired.occurrenceId), @@ -379,7 +1566,7 @@ class StudySessionManagerTest { fixture.store.appendFailure = IllegalStateException("storage unavailable") val failure = runCatching { - fixture.manager.rescheduleInterventions(recoverStalePosting = true) + fixture.manager.reconcileScheduledWork(recoverStalePosting = true) }.exceptionOrNull() assertTrue(failure is IllegalStateException) @@ -388,7 +1575,7 @@ class StudySessionManagerTest { } @Test - fun postedCommitSurvivesSuccessorSchedulingFailureAndRetry() = runTest { + fun postedCommitSurvivesSuccessorSchedulingSafetyPauseAndRetryAfterResume() = runTest { val intervention = InterventionConfiguration( "notice-one", NotificationAction("Study check-in", "Check in"), @@ -423,8 +1610,13 @@ class StudySessionManagerTest { OccurrenceState.NOTIFICATION_POSTED, fixture.manager.snapshot.value.runtime.metadata?.occurrences?.get(first.occurrenceId)?.state, ) - + assertEquals(ExperimentState.PAUSED, fixture.manager.snapshot.value.runtime.metadata?.state) + assertEquals( + TransitionReason.WORK_SCHEDULING_FAILURE, + fixture.manager.snapshot.value.runtime.metadata?.transitions?.last()?.reason, + ) fixture.work.failEnqueue = false + assertEquals(CommandResult.Success, fixture.manager.resume()) val enqueuedBeforeRetry = fixture.work.enqueuedOccurrences.size fixture.manager.scheduleSuccessor(first.occurrenceId) assertEquals(enqueuedBeforeRetry + 1, fixture.work.enqueuedOccurrences.size) @@ -444,8 +1636,8 @@ class StudySessionManagerTest { return fixture( configuration, activeEnvelope = byteArrayOf(9), - initialMetadata = StudyMetadata.initial(configuration.experimentId, configuration.configurationId).copy( - state = ExperimentState.RUNNING, + initialMetadata = startedMetadata( + configuration, occurrences = mapOf(posted.occurrenceId to posted), ), ).also { it.manager.initialize() } @@ -481,9 +1673,14 @@ class StudySessionManagerTest { runCurrent() assertEquals(ExperimentState.PAUSED, fixture.manager.snapshot.value.runtime.metadata?.state) + assertEquals( + TransitionReason.WORK_SCHEDULING_FAILURE, + fixture.manager.snapshot.value.runtime.metadata?.transitions?.last()?.reason, + ) assertEquals(1, fixture.collector.pauseCount) assertEquals(1, fixture.host.stopCount) - assertEquals(1, fixture.work.cancelCount) + assertEquals(1, fixture.work.cancelInterventionCount) + assertEquals(0, fixture.work.cancelCount) } @Test @@ -694,56 +1891,611 @@ class StudySessionManagerTest { assertNull(manager.snapshot.value.upload) } + @Test + fun startCollectorCancellationCompletesTypedContainmentBeforePropagating() = runTest { + val configuration = configuration() + val fixture = fixture(configuration) + fixture.prepareReadyStudy() + fixture.collector.startEntered = CompletableDeferred() + fixture.collector.startGate = CompletableDeferred() + + val command = async { fixture.manager.start() } + fixture.collector.startEntered?.await() + command.cancel() + runCurrent() + + val failure = runCatching { command.await() }.exceptionOrNull() + assertTrue(failure is CancellationException) + assertEquals(ExperimentState.PAUSED, fixture.store.metadata?.state) + assertEquals( + TransitionReason.COLLECTION_HOST_FAILURE, + fixture.store.metadata?.transitions?.last()?.reason, + ) + assertEquals(1, fixture.host.stopCount) + assertEquals(1, fixture.collector.pauseCount) + assertNull(fixture.pauseStore.pendingReason) + assertTrue(runCatching { fixture.collector.emit(1) }.isFailure) + } + + @Test + fun resumeCollectorCancellationCompletesTypedContainmentBeforePropagating() = runTest { + val fixture = fixture(configuration()) + fixture.prepareRunningStudy() + assertEquals(CommandResult.Success, fixture.manager.pause()) + fixture.collector.resumeEntered = CompletableDeferred() + fixture.collector.resumeGate = CompletableDeferred() + + val command = async { fixture.manager.resume() } + fixture.collector.resumeEntered?.await() + command.cancel() + runCurrent() + + val failure = runCatching { command.await() }.exceptionOrNull() + assertTrue(failure is CancellationException) + assertEquals(ExperimentState.PAUSED, fixture.store.metadata?.state) + assertEquals( + TransitionReason.COLLECTION_HOST_FAILURE, + fixture.store.metadata?.transitions?.last()?.reason, + ) + assertEquals(1, fixture.collector.resumeCount) + assertTrue(runCatching { fixture.collector.emit(1) }.isFailure) + } + + @Test + fun cancelledPostStartWorkAcknowledgementClosesAdmissionBeforeCleanupCanSuspend() = runTest { + val configuration = configuration() + val fixture = fixture(configuration) + fixture.prepareReadyStudy() + fixture.work.ensureGate = CompletableDeferred() + fixture.work.cancelInterventionEntered = CompletableDeferred() + fixture.work.cancelInterventionGate = CompletableDeferred() + + val command = async { fixture.manager.start() } + runCurrent() + assertEquals(1, fixture.collector.startCount) + command.cancel() + fixture.work.cancelInterventionEntered?.await() + + assertTrue(runCatching { fixture.collector.emit(1) }.isFailure) + assertEquals(ExperimentState.PAUSED, fixture.store.metadata?.state) + assertEquals( + SafetyPauseReason.WORK_SCHEDULING_FAILURE, + fixture.pauseStore.pendingReason, + ) + + val recovered = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = fixture.store.metadata, + pauseStore = FakeSafetyPauseStore(fixture.pauseStore.pendingReason), + ) + recovered.manager.initialize() + runCurrent() + assertEquals(ExperimentState.PAUSED, recovered.store.metadata?.state) + assertEquals(0, recovered.host.startCount) + assertEquals(0, recovered.collector.startCount) + + fixture.work.cancelInterventionGate?.complete(Unit) + assertTrue(runCatching { command.await() }.exceptionOrNull() is CancellationException) + } + + @Test + fun recoveredCollectorCancellationLeavesDurablePauseForTheNextProcess() = runTest { + val configuration = configuration() + val fixture = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = startedMetadata(configuration), + ) + fixture.collector.startEntered = CompletableDeferred() + fixture.collector.startGate = CompletableDeferred() + + val initialization = async { fixture.manager.initialize() } + fixture.collector.startEntered?.await() + initialization.cancel() + runCurrent() + + assertTrue(runCatching { initialization.await() }.exceptionOrNull() is CancellationException) + assertEquals(ExperimentState.PAUSED, fixture.store.metadata?.state) + val recovered = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = fixture.store.metadata, + ) + recovered.manager.initialize() + runCurrent() + assertEquals(ExperimentState.PAUSED, recovered.store.metadata?.state) + assertEquals(0, recovered.host.startCount) + assertEquals(0, recovered.collector.startCount) + } + + @Test + fun uncertainStartCommitIsAcknowledgedBackToReadyBeforeCancellationPropagates() = runTest { + val configuration = configuration() + val fixture = fixture(configuration) + fixture.prepareReadyStudy() + fixture.store.saveAfterCommitFailure = CancellationException("start acknowledgement lost") + + val failure = runCatching { fixture.manager.start() }.exceptionOrNull() + + assertTrue(failure is CancellationException) + assertEquals(ExperimentState.READY, fixture.store.metadata?.state) + assertNull(fixture.pauseStore.pendingReason) + assertEquals(1, fixture.host.stopCount) + val recovered = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = fixture.store.metadata, + ) + recovered.manager.initialize() + assertEquals(0, recovered.host.startCount) + assertEquals(0, recovered.collector.startCount) + } + + @Test + fun uncertainResumeCommitIsAcknowledgedBackToPausedAfterFailure() = runTest { + val fixture = fixture(configuration()) + fixture.prepareRunningStudy() + assertEquals(CommandResult.Success, fixture.manager.pause()) + fixture.store.saveAfterCommitFailure = IllegalStateException("resume acknowledgement lost") + + assertEquals(CommandResult.Failed("STORAGE_WRITE_FAILED"), fixture.manager.resume()) + + assertEquals(ExperimentState.PAUSED, fixture.store.metadata?.state) + assertNull(fixture.pauseStore.pendingReason) + assertEquals(0, fixture.collector.resumeCount) + } + + @Test + fun processDeathAfterTeardownWitnessButBeforeRuntimeNeverReopensRunning() = runTest { + val configuration = configuration() + val fixture = fixture(configuration) + fixture.prepareRunningStudy() + fixture.pauseStore.markEntered = CompletableDeferred() + fixture.pauseStore.markReturnGate = CompletableDeferred() + + val pause = async { fixture.manager.pause() } + fixture.pauseStore.markEntered?.await() + val recovered = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = fixture.store.metadata, + pauseStore = FakeSafetyPauseStore(fixture.pauseStore.pendingReason), + ) + recovered.manager.initialize() + runCurrent() + + assertEquals(ExperimentState.PAUSED, recovered.store.metadata?.state) + assertEquals( + TransitionReason.COLLECTION_TEARDOWN_FAILURE, + recovered.store.metadata?.transitions?.last()?.reason, + ) + assertEquals(0, recovered.host.startCount) + assertEquals(0, recovered.collector.startCount) + + fixture.pauseStore.markReturnGate?.complete(Unit) + assertEquals(CommandResult.Success, pause.await()) + } + + @Test + fun cancellationAfterPrearmAcknowledgementEntersTypedSafetyPauseBeforePropagation() = runTest { + val commands = listOf CommandResult>>( + "pause" to { it.manager.pause() }, + "finish" to { it.manager.finish() }, + "duration" to { it.completeDurationCommand() }, + "withdraw" to { it.manager.withdraw() }, + ) + commands.forEach { (name, command) -> + val configuration = configuration() + val fixture = fixture(configuration) + fixture.prepareRunningStudy() + fixture.pauseStore.markEntered = CompletableDeferred() + fixture.pauseStore.markReturnGate = CompletableDeferred() + + val operation = async { command(fixture) } + fixture.pauseStore.markEntered?.await() + operation.cancel() + fixture.pauseStore.markReturnGate?.complete(Unit) + runCurrent() + + assertTrue( + "$name cancellation must propagate", + runCatching { operation.await() }.exceptionOrNull() is CancellationException, + ) + assertEquals(name, ExperimentState.PAUSED, fixture.store.metadata?.state) + assertEquals( + name, + TransitionReason.COLLECTION_TEARDOWN_FAILURE, + fixture.store.metadata?.transitions?.last()?.reason, + ) + assertNull(name, fixture.pauseStore.pendingReason) + assertTrue(name, runCatching { fixture.collector.emit(1) }.isFailure) + + val recovered = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = fixture.store.metadata, + ) + recovered.manager.initialize() + assertEquals(name, 0, recovered.host.startCount) + assertEquals(name, 0, recovered.collector.startCount) + } + } + + @Test + fun cancelledAccessPauseFinishesAppOwnedProtocolAndFreshRecoveryStaysClosed() = runTest { + val configuration = configuration() + val fixture = fixture(configuration) + fixture.prepareRunningStudy() + fixture.collector.pauseEntered = CompletableDeferred() + fixture.collector.pauseGate = CompletableDeferred() + fixture.access.granted -= AccessKind.NOTIFICATIONS + + val reconcile = async { fixture.manager.reconcileAccess() } + fixture.collector.pauseEntered?.await() + reconcile.cancel() + val recovered = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = fixture.store.metadata, + pauseStore = FakeSafetyPauseStore(fixture.pauseStore.pendingReason), + ) + recovered.manager.initialize() + runCurrent() + assertEquals(ExperimentState.PAUSED, recovered.store.metadata?.state) + assertEquals(0, recovered.host.startCount) + assertEquals(0, recovered.collector.startCount) + + fixture.collector.pauseGate?.complete(Unit) + runCurrent() + assertTrue(runCatching { reconcile.await() }.exceptionOrNull() is CancellationException) + assertEquals(ExperimentState.PAUSED, fixture.store.metadata?.state) + assertNull(fixture.pauseStore.pendingReason) + assertTrue(runCatching { fixture.collector.emit(1) }.isFailure) + } + + @Test + fun processDeathAfterParticipantPauseCommitPreservesHistoryAndFinishesCleanup() = runTest { + val configuration = configuration() + val fixture = fixture(configuration) + fixture.prepareRunningStudy() + fixture.work.cancelInterventionEntered = CompletableDeferred() + fixture.work.cancelInterventionGate = CompletableDeferred() + + val pause = async { fixture.manager.pause() } + fixture.work.cancelInterventionEntered?.await() + assertEquals(TransitionReason.PARTICIPANT_PAUSED, fixture.store.metadata?.transitions?.last()?.reason) + val recovered = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = fixture.store.metadata, + pauseStore = FakeSafetyPauseStore(fixture.pauseStore.pendingReason), + ) + recovered.manager.initialize() + runCurrent() + + assertEquals(ExperimentState.PAUSED, recovered.store.metadata?.state) + assertEquals( + TransitionReason.PARTICIPANT_PAUSED, + recovered.store.metadata?.transitions?.last()?.reason, + ) + assertEquals(0, recovered.host.startCount) + assertEquals(0, recovered.collector.startCount) + + fixture.work.cancelInterventionGate?.complete(Unit) + assertEquals(CommandResult.Success, pause.await()) + } + + @Test + fun terminalCommitRecoveryCancelsCollectionWorkAndRepairsOnlyUploadTail() = runTest { + val configuration = configuration( + upload = UploadConfiguration("https://intake.example.invalid/v1", 60, false), + ) + val fixture = fixture(configuration) + fixture.prepareRunningStudy() + fixture.work.cancelCollectionEntered = CompletableDeferred() + fixture.work.cancelCollectionGate = CompletableDeferred() + + val finish = async { fixture.manager.finish() } + fixture.work.cancelCollectionEntered?.await() + assertEquals(ExperimentState.COMPLETED, fixture.store.metadata?.state) + val recovered = fixture( + configuration = configuration, + activeEnvelope = byteArrayOf(1), + initialMetadata = fixture.store.metadata, + pauseStore = FakeSafetyPauseStore(fixture.pauseStore.pendingReason), + ) + recovered.manager.initialize() + runCurrent() + + assertEquals(ExperimentState.COMPLETED, recovered.store.metadata?.state) + assertEquals(0, recovered.host.startCount) + assertEquals(0, recovered.collector.startCount) + // The typed teardown witness is reconciled first, then terminal recovery independently + // enforces the same idempotent cleanup before KEEP-repairing the upload tail. + assertEquals(2, recovered.work.cancelCollectionCount) + assertEquals(0, recovered.work.cancelCount) + assertEquals(ExperimentState.COMPLETED, recovered.work.ensuredMetadata.last().state) + + recovered.manager.reconcileScheduledWork(recoverStalePosting = true) + assertEquals(3, recovered.work.cancelCollectionCount) + assertEquals(ExperimentState.COMPLETED, recovered.work.ensuredMetadata.last().state) + + fixture.work.cancelCollectionGate?.complete(Unit) + assertEquals(CommandResult.Success, finish.await()) + } + + @Test + fun terminalCleanupFailureKeepsWitnessUntilStateAwareWorkerCancellationSucceeds() = runTest { + val commands = listOf CommandResult>>( + Triple("finish", ExperimentState.COMPLETED) { it.manager.finish() }, + Triple("duration", ExperimentState.COMPLETED) { it.completeDurationCommand() }, + Triple("withdraw", ExperimentState.WITHDRAWN) { it.manager.withdraw() }, + ) + commands.forEach { (name, terminalState, command) -> + val fixture = fixture(configuration()) + fixture.prepareRunningStudy() + fixture.work.cancelCollectionFailure = IllegalStateException("$name cancellation failed") + + assertEquals( + name, + CommandResult.Failed("SAFETY_PAUSE_SHUTDOWN_FAILED"), + command(fixture), + ) + assertEquals(name, terminalState, fixture.store.metadata?.state) + assertEquals( + name, + SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE, + fixture.pauseStore.pendingReason, + ) + assertEquals( + name, + setOf(SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE), + fixture.work.activeSafetyPauseReasons, + ) + + fixture.work.cancelCollectionFailure = null + assertTrue( + name, + fixture.manager.retrySafetyPause( + fixture.manager.snapshot.value.configuration?.experimentId ?: error("missing study"), + SafetyPauseReason.COLLECTION_TEARDOWN_FAILURE, + ), + ) + assertEquals(name, 2, fixture.work.cancelCollectionCount) + assertNull(name, fixture.pauseStore.pendingReason) + assertTrue(name, fixture.work.activeSafetyPauseReasons.isEmpty()) + } + } + + @Test + fun optionalLocationHostPromotionCancellationPausesEveryCollector() = runTest { + val locationAccess = setOf( + AccessKind.FINE_LOCATION, + AccessKind.LOCATION_SERVICES, + AccessKind.BACKGROUND_LOCATION, + ) + val lifecycle = AppLifecycleConfiguration(required = true) + val location = locationConfiguration(required = false) + val fixture = fixture( + configuration = configuration(collectors = listOf(lifecycle, location)), + grantedAccess = setOf(AccessKind.NOTIFICATIONS), + collectorAccessKindsById = mapOf(location.id to locationAccess), + ) + fixture.prepareRunningStudy() + fixture.host.locationStartEntered = CompletableDeferred() + fixture.host.locationStartGate = CompletableDeferred() + fixture.access.granted += locationAccess + + val reconcile = async { fixture.manager.reconcileAccess() } + fixture.host.locationStartEntered?.await() + reconcile.cancel() + runCurrent() + + assertTrue(runCatching { reconcile.await() }.exceptionOrNull() is CancellationException) + assertEquals(ExperimentState.PAUSED, fixture.store.metadata?.state) + assertEquals( + TransitionReason.COLLECTION_HOST_FAILURE, + fixture.store.metadata?.transitions?.last()?.reason, + ) + fixture.collectors.values.forEach { collector -> + assertTrue(runCatching { collector.emit(1) }.isFailure) + } + } + + @Test + fun optionalLocationHostDemotionCancellationPausesEveryCollector() = runTest { + val locationAccess = setOf( + AccessKind.FINE_LOCATION, + AccessKind.LOCATION_SERVICES, + AccessKind.BACKGROUND_LOCATION, + ) + val lifecycle = AppLifecycleConfiguration(required = true) + val location = locationConfiguration(required = false) + val fixture = fixture( + configuration = configuration(collectors = listOf(lifecycle, location)), + grantedAccess = setOf(AccessKind.NOTIFICATIONS) + locationAccess, + collectorAccessKindsById = mapOf(location.id to locationAccess), + ) + fixture.prepareRunningStudy() + fixture.host.nonLocationStartEntered = CompletableDeferred() + fixture.host.nonLocationStartGate = CompletableDeferred() + fixture.access.granted -= locationAccess + + val reconcile = async { fixture.manager.reconcileAccess() } + fixture.host.nonLocationStartEntered?.await() + reconcile.cancel() + runCurrent() + + assertTrue(runCatching { reconcile.await() }.exceptionOrNull() is CancellationException) + assertEquals(ExperimentState.PAUSED, fixture.store.metadata?.state) + fixture.collectors.values.forEach { collector -> + assertTrue(runCatching { collector.emit(1) }.isFailure) + } + } + + private suspend fun Fixture.prepareReadyStudy() { + manager.initialize() + manager.importSignedConfiguration(byteArrayOf(1)) + check(manager.reviewStudy() == CommandResult.Success) + check(manager.acceptConsent() == CommandResult.Success) + check(manager.completeAccessSetup() == CommandResult.Success) + } + + private suspend fun Fixture.prepareRunningStudy() { + prepareReadyStudy() + check(manager.start() == CommandResult.Success) + } + + private fun Fixture.forceDurationBoundary(offsetNanos: Long) { + val configuration = requireNotNull(manager.snapshot.value.configuration) + val startedAt = requireNotNull(store.metadata).transitions + .single { it.reason == TransitionReason.PARTICIPANT_STARTED } + .time + val durationNanos = configuration.durationHours.toLong() * NANOS_PER_HOUR + clocks.force( + ResearchTime( + wallTimeUtcMillis = startedAt.wallTimeUtcMillis + durationNanos / NANOS_PER_MILLISECOND, + elapsedRealtimeNanos = startedAt.elapsedRealtimeNanos + durationNanos + offsetNanos, + bootSessionId = startedAt.bootSessionId, + ), + ) + } + + private suspend fun Fixture.completeDurationCommand(): CommandResult { + forceDurationBoundary(offsetNanos = 0) + return when (val result = manager.completeAfterDurationIfDue()) { + DurationCompletionResult.Completed -> CommandResult.Success + is DurationCompletionResult.Failed -> result.commandResult + DurationCompletionResult.Inactive -> error("Test study unexpectedly became inactive") + is DurationCompletionResult.NotDue -> error("Test deadline unexpectedly remained early") + } + } + private fun TestScope.fixture( configuration: StudyConfiguration, activeEnvelope: ByteArray? = null, activeRecord: ActiveStudyRecord? = activeEnvelope?.let(ActiveStudyRecord::Active), initialMetadata: StudyMetadata? = null, uploader: StudyUploader = FakeUploader(), + grantedAccess: Set = setOf(AccessKind.NOTIFICATIONS), + omittedAccess: Set = emptySet(), + collectorAccessKinds: Set = emptySet(), + collectorAccessKindsById: Map> = emptyMap(), + pauseStore: FakeSafetyPauseStore = FakeSafetyPauseStore(), + recoveredBootSessionId: String? = null, ): Fixture { val active = FakeActiveStudyStore(activeRecord) val store = FakeStudyStore(initialMetadata) - val collector = FakeCollector() - val registry = CollectorRegistry(listOf(FakePlugin(collector))) - val access = FakeAccessGateway() - val host = FakeHost() - val work = FakeWorkScheduler() + val lifecycleEvents = mutableListOf() + val collectors = configuration.collectors.associate { collectorConfiguration -> + collectorConfiguration.id to FakeCollector(collectorConfiguration.id, lifecycleEvents) + } + val registry = CollectorRegistry( + collectors.map { (collectorId, collector) -> + FakePlugin( + collector = collector, + collectorId = collectorId, + accessKinds = collectorAccessKindsById[collectorId] ?: collectorAccessKinds, + ) + }, + ) + val access = FakeAccessGateway(grantedAccess, omittedAccess) + val host = FakeHost(lifecycleEvents) + val work = FakeWorkScheduler(lifecycleEvents) + val recoveredBoundary = initialMetadata?.transitions?.maxByOrNull { + it.time.wallTimeUtcMillis + }?.time + val clockBootSessionId = recoveredBootSessionId + ?: recoveredBoundary?.bootSessionId + ?: UUID.randomUUID().toString() + val recoveredWallTime = recoveredBoundary?.wallTimeUtcMillis ?: 0L + val recoveredElapsedTime = recoveredBoundary + ?.takeIf { it.bootSessionId == clockBootSessionId } + ?.elapsedRealtimeNanos + ?: 0L + val clocks = FakeClocks(recoveredWallTime, recoveredElapsedTime, clockBootSessionId) val manager = StudySessionManager( activeStudyStore = active, verifier = StudyVerifier { verified(configuration) }, storeFactory = StudyStoreFactory { _, _ -> store }, - runtimeFactory = ExperimentRuntimeFactory { verified, createdStore, availableAccess -> + runtimeFactory = ExperimentRuntimeFactory { verified, createdStore, safetyPauseWitness -> ExperimentRuntime( verified, createdStore, registry, - FakeClocks(), + clocks, backgroundScope, - availableAccess, + safetyPauseWitness, ) }, collectorRegistry = registry, accessGateway = access, collectionHost = host, + safetyPauseStore = pauseStore, workScheduler = work, exporter = FakeExporter(), uploader = uploader, accessPolicy = StudyAccessPolicy(), scope = backgroundScope, ) - return Fixture(manager, active, store, collector, host, work, uploader, access) + return Fixture( + manager, + active, + store, + collectors, + host, + work, + uploader, + access, + pauseStore, + clocks, + lifecycleEvents, + ) } private data class Fixture( val manager: StudySessionManager, val active: FakeActiveStudyStore, val store: FakeStudyStore, - val collector: FakeCollector, + val collectors: Map, val host: FakeHost, val work: FakeWorkScheduler, val uploader: StudyUploader, val access: FakeAccessGateway, - ) + val pauseStore: FakeSafetyPauseStore, + val clocks: FakeClocks, + val lifecycleEvents: MutableList, + ) { + val collector: FakeCollector get() = collectors.values.single() + } + + private class FakeSafetyPauseStore( + var pendingReason: SafetyPauseReason? = null, + ) : SafetyPauseStore { + var markFailure: Exception? = null + var clearFailure: Exception? = null + var readFailure: Exception? = null + var markEntered: CompletableDeferred? = null + var markReturnGate: CompletableDeferred? = null + + override suspend fun pendingReason(): SafetyPauseReason? { + readFailure?.let { throw it } + return pendingReason + } + + override suspend fun markPending(reason: SafetyPauseReason) { + markFailure?.let { throw it } + pendingReason = reason + markEntered?.complete(Unit) + markReturnGate?.await() + } + + override suspend fun clear() { + clearFailure?.let { throw it } + pendingReason = null + } + } private class FakeActiveStudyStore(initial: ActiveStudyRecord?) : ActiveStudyStore { var record: ActiveStudyRecord? = initial @@ -764,7 +2516,8 @@ class StudySessionManagerTest { } private class FakeStudyStore(initial: StudyMetadata?) : StudyStore { - private var metadata = initial + var metadata = initial + private set private val events = mutableListOf() var cleared = false var usedBytes = 0L @@ -773,6 +2526,9 @@ class StudySessionManagerTest { var clearAttempts = 0 var clearFailure: Exception? = null var appendFailure: Exception? = null + var saveFailure: Exception? = null + var saveAfterCommitFailure: Throwable? = null + var saveCount = 0 override suspend fun storageUsage() = StorageUsage(usedBytes, quotaBytes) @@ -792,13 +2548,26 @@ class StudySessionManagerTest { override suspend fun loadMetadata(): StudyMetadata? = metadata override suspend fun initialize(metadata: StudyMetadata) { this.metadata = metadata } - override suspend fun saveMetadata(metadata: StudyMetadata) { this.metadata = metadata } + override suspend fun saveMetadata(metadata: StudyMetadata) { + saveFailure?.let { throw it } + saveCount += 1 + this.metadata = metadata + saveAfterCommitFailure?.let { failure -> + saveAfterCommitFailure = null + throw failure + } + } override suspend fun appendEvent(event: RecordedEvent) { events += event } - override suspend fun appendEventAtomically(event: RecordedEvent, metadata: StudyMetadata) { + override suspend fun appendEventAtomically( + event: RecordedEvent, + metadata: StudyMetadata, + failureTime: ResearchTime, + ) { appendFailure?.let { throw it } events += event this.metadata = metadata } + override suspend fun resolvePendingAppendFailure(reason: TransitionReason): StudyMetadata? = null override suspend fun readEvents( fromSequenceInclusive: Long, upToSequenceInclusive: Long, @@ -819,20 +2588,25 @@ class StudySessionManagerTest { private class FakePlugin( private val collector: FakeCollector, + collectorId: String, + accessKinds: Set, ) : CollectorPlugin { override val descriptor = CollectorDescriptor( - id = AppLifecycleConfiguration.ID, + id = collectorId, displayName = "Test collector", privacyClass = PrivacyClass.SENSITIVE, - eventContract = requireNotNull(ProtocolEventContracts[AppLifecycleConfiguration.ID]), + eventContract = requireNotNull(ProtocolEventContracts[collectorId]), + accessKinds = accessKinds, ) - override fun accessRequirements(configuration: CollectorConfiguration): Set = emptySet() override fun create(configuration: CollectorConfiguration, context: CollectorContext): Collector = collector.also { it.context = context } } - private class FakeCollector : Collector { + private class FakeCollector( + private val collectorId: String, + private val lifecycleEvents: MutableList, + ) : Collector { private val mutableHealth = MutableStateFlow(CollectorHealth(CollectorStatus.STOPPED)) override val health: StateFlow = mutableHealth var context: CollectorContext? = null @@ -840,11 +2614,48 @@ class StudySessionManagerTest { var pauseCount = 0 var resumeCount = 0 var stopCount = 0 - - override suspend fun start() { startCount += 1; mutableHealth.value = CollectorHealth(CollectorStatus.ACTIVE) } - override suspend fun pause() { pauseCount += 1; mutableHealth.value = CollectorHealth(CollectorStatus.PAUSED) } - override suspend fun resume() { resumeCount += 1; mutableHealth.value = CollectorHealth(CollectorStatus.ACTIVE) } - override suspend fun stop() { stopCount += 1; mutableHealth.value = CollectorHealth(CollectorStatus.STOPPED) } + var failNextPause = false + var resourceOwned = false + var startEntered: CompletableDeferred? = null + var startGate: CompletableDeferred? = null + var pauseEntered: CompletableDeferred? = null + var pauseGate: CompletableDeferred? = null + var resumeEntered: CompletableDeferred? = null + var resumeGate: CompletableDeferred? = null + + override val requiresStop: Boolean get() = resourceOwned + + override suspend fun start() { + lifecycleEvents += "collector:start" + startCount += 1 + resourceOwned = true + startEntered?.complete(Unit) + startGate?.await() + mutableHealth.value = CollectorHealth(CollectorStatus.ACTIVE) + } + override suspend fun pause() { + lifecycleEvents += "collector:pause" + pauseCount += 1 + pauseEntered?.complete(Unit) + pauseGate?.await() + if (failNextPause) { + failNextPause = false + error("collector pause failed") + } + mutableHealth.value = CollectorHealth(CollectorStatus.PAUSED) + } + override suspend fun resume() { + lifecycleEvents += "collector:resume" + resumeCount += 1 + resumeEntered?.complete(Unit) + resumeGate?.await() + mutableHealth.value = CollectorHealth(CollectorStatus.ACTIVE) + } + override suspend fun stop() { + stopCount += 1 + resourceOwned = false + mutableHealth.value = CollectorHealth(CollectorStatus.STOPPED) + } /** Records [count] events through the real admission path, so sequence numbers are genuine. */ suspend fun emit(count: Int) { @@ -854,7 +2665,7 @@ class StudySessionManagerTest { sink.emit( token, EventDraft( - collectorId = AppLifecycleConfiguration.ID, + collectorId = collectorId, payloadSchemaVersion = 1, observedTime = checkNotNull(context).clocks.now(), payloadType = "ACTIVITY_RESUMED", @@ -865,74 +2676,182 @@ class StudySessionManagerTest { } } - private class FakeClocks : ResearchClocks { + private class FakeClocks( + private val wallTimeBase: Long = 0, + private val elapsedRealtimeBase: Long = 0, + private val bootSessionId: String = UUID.randomUUID().toString(), + ) : ResearchClocks { private var tick = 0L - override fun now(): ResearchTime = ResearchTime(++tick * 1_000, tick * 1_000, "boot-test") + private var forcedTime: ResearchTime? = null + + override fun now(): ResearchTime = forcedTime ?: ResearchTime( + wallTimeBase + ++tick * 1_000, + elapsedRealtimeBase + tick * 1_000_000_000, + bootSessionId, + ) + + fun force(time: ResearchTime) { + forcedTime = time + } } - private class FakeAccessGateway : StudyAccessGateway { - val granted = mutableSetOf() - override fun inspect(requirements: Set): List = requirements - .sortedBy { it.kind.name } - .map { AccessStatus(it, it.kind in granted) } - override fun grantedKinds(requirements: Set): Set = - requirements.map(AccessRequirement::kind).filterTo(mutableSetOf()) { it in granted } + private class FakeAccessGateway( + initiallyGranted: Set, + private val omitted: Set, + ) : StudyAccessGateway { + val granted = initiallyGranted.toMutableSet() + var lastRequest: AccessInspectionRequest? = null + var failure: RuntimeException? = null + var inspectCount = 0 + + override suspend fun inspect(request: AccessInspectionRequest): AccessSnapshot { + inspectCount += 1 + failure?.let { throw it } + lastRequest = request + return AccessSnapshot( + request.requirements.filterNot { it.kind in omitted }.sortedBy { it.kind.ordinal }.map { requirement -> + AccessStatus( + requirement = requirement, + resolution = if (requirement.kind in granted) { + AccessResolution.Satisfied + } else { + AccessResolution.Unavailable(AccessUnavailableReason.SYSTEM_HANDLER_MISSING) + }, + guidance = null, + ) + }, + ) + } } - private class FakeHost : StudyCollectionHost { + private class FakeHost( + private val lifecycleEvents: MutableList, + ) : StudyCollectionHost { var startCount = 0 var stopCount = 0 + val locationStarts = mutableListOf() + var startFailure: Exception? = null + var locationStartFailure: Exception? = null + var nonLocationStartFailure: Exception? = null var stopFailure: Exception? = null - override fun start(studyTitle: String, usesLocation: Boolean) { startCount += 1 } + var locationStartEntered: CompletableDeferred? = null + var locationStartGate: CompletableDeferred? = null + var nonLocationStartEntered: CompletableDeferred? = null + var nonLocationStartGate: CompletableDeferred? = null + override suspend fun start(studyTitle: String, usesLocation: Boolean) { + startCount += 1 + locationStarts += usesLocation + if (usesLocation) { + locationStartEntered?.complete(Unit) + locationStartGate?.await() + } else { + nonLocationStartEntered?.complete(Unit) + nonLocationStartGate?.await() + } + startFailure?.let { throw it } + if (usesLocation) locationStartFailure?.let { throw it } + if (!usesLocation) nonLocationStartFailure?.let { throw it } + lifecycleEvents += "host:$usesLocation" + } override fun stop() { stopCount += 1 stopFailure?.let { throw it } } } - private class FakeWorkScheduler : StudyWorkScheduler { + private class FakeWorkScheduler( + private val lifecycleEvents: MutableList, + ) : StudyWorkScheduler { var scheduleCount = 0 var cancelCount = 0 var cancelCollectionCount = 0 var cancelInterventionCount = 0 + var safetyPauseScheduleCount = 0 + var safetyPauseCancelCount = 0 + var scheduledSafetyPauseReason: SafetyPauseReason? = null + val activeSafetyPauseReasons = mutableSetOf() + var safetyPauseScheduleFailure: Exception? = null + var safetyPauseCancelFailure: Exception? = null + var safetyPauseInspectionFailure: Exception? = null + var safetyPauseCancellationGate: CompletableDeferred? = null + var cancelInterventionEntered: CompletableDeferred? = null + var cancelInterventionGate: CompletableDeferred? = null + var cancelCollectionEntered: CompletableDeferred? = null + var cancelCollectionGate: CompletableDeferred? = null + var cancelCollectionFailure: Exception? = null var failSchedule = false + var ensureGate: CompletableDeferred? = null var failEnqueue = false + var replacementFailure: Exception? = null var cancelFailure: Exception? = null var replacementDeliveries = emptyList() var replacementExpiries = emptyList() val cancelledNotificationIds = mutableSetOf() val enqueuedOccurrences = mutableListOf() - override fun schedule(configuration: StudyConfiguration) { + val ensuredMetadata = mutableListOf() + val ensureObservedAt = mutableListOf() + override suspend fun ensureCollectionWork( + configuration: StudyConfiguration, + metadata: StudyMetadata, + observedAt: ResearchTime, + ) { scheduleCount += 1 + ensuredMetadata += metadata + ensureObservedAt += observedAt + lifecycleEvents += "work:ensure" + ensureGate?.await() if (failSchedule) error("Scheduling failed") } - override fun replaceInterventionWork( + override suspend fun replaceInterventionWork( configuration: StudyConfiguration, deliveries: List, expiries: List, ) { + replacementFailure?.let { throw it } replacementDeliveries = deliveries replacementExpiries = expiries } - override fun enqueueOccurrence( + override suspend fun enqueueOccurrence( configuration: StudyConfiguration, occurrence: InterventionOccurrence, ) { if (failEnqueue) error("Enqueue failed") enqueuedOccurrences += occurrence } - override fun cancelInterventionWork(experimentId: String, occurrenceIds: Set) { + override suspend fun cancelInterventionWork(experimentId: String, occurrenceIds: Set) { cancelInterventionCount += 1 cancelledNotificationIds += occurrenceIds + cancelInterventionEntered?.complete(Unit) + cancelInterventionGate?.await() } override fun cancelInterventionNotifications(occurrenceIds: Set) { cancelledNotificationIds += occurrenceIds } - override fun cancelCollectionWork(experimentId: String, occurrenceIds: Set) { + override suspend fun scheduleSafetyPauseRetry(experimentId: String, reason: SafetyPauseReason) { + safetyPauseScheduleCount += 1 + scheduledSafetyPauseReason = reason + safetyPauseScheduleFailure?.let { throw it } + activeSafetyPauseReasons += reason + } + override suspend fun pendingSafetyPauseReason(experimentId: String): SafetyPauseReason? { + safetyPauseInspectionFailure?.let { throw it } + check(activeSafetyPauseReasons.size <= 1) + return activeSafetyPauseReasons.singleOrNull() + } + override suspend fun cancelSafetyPauseRetry() { + safetyPauseCancelCount += 1 + safetyPauseCancellationGate?.await() + safetyPauseCancelFailure?.let { throw it } + activeSafetyPauseReasons.clear() + } + override suspend fun cancelCollectionWork(experimentId: String, occurrenceIds: Set) { cancelCollectionCount += 1 cancelledNotificationIds += occurrenceIds + cancelCollectionEntered?.complete(Unit) + cancelCollectionGate?.await() + cancelCollectionFailure?.let { throw it } } - override fun cancel(experimentId: String) { + override suspend fun cancel(experimentId: String) { cancelCount += 1 cancelFailure?.let { throw it } } @@ -1006,6 +2925,7 @@ class StudySessionManagerTest { } private fun configuration( + collectors: List = listOf(AppLifecycleConfiguration(required = true)), interventions: List = emptyList(), surveys: List = emptyList(), upload: UploadConfiguration? = null, @@ -1025,7 +2945,7 @@ class StudySessionManagerTest { durationHours = 1, consentDocumentVersion = "v1", consentSummary = "Test consent", - collectors = listOf(AppLifecycleConfiguration(required = true)), + collectors = collectors, surveys = surveys, interventions = interventions, maximumLocalBytes = 16_777_216, @@ -1034,6 +2954,48 @@ class StudySessionManagerTest { upload = upload, ) + private fun startedMetadata( + configuration: StudyConfiguration, + state: ExperimentState = ExperimentState.RUNNING, + occurrences: Map = emptyMap(), + start: ResearchTime = ResearchTime(100, 100, "boot-before-recovery"), + ): StudyMetadata { + require(state in setOf(ExperimentState.RUNNING, ExperimentState.PAUSED)) + val transitions = mutableListOf( + ExperimentTransition( + from = ExperimentState.READY, + to = ExperimentState.RUNNING, + reason = TransitionReason.PARTICIPANT_STARTED, + time = start, + ), + ) + if (state == ExperimentState.PAUSED) { + transitions += ExperimentTransition( + from = ExperimentState.RUNNING, + to = ExperimentState.PAUSED, + reason = TransitionReason.PARTICIPANT_PAUSED, + time = start.copy( + wallTimeUtcMillis = start.wallTimeUtcMillis + 1, + elapsedRealtimeNanos = start.elapsedRealtimeNanos + 1, + ), + ) + } + return StudyMetadata.initial(configuration.experimentId, configuration.configurationId).copy( + state = state, + transitions = transitions, + occurrences = occurrences, + ) + } + + private fun locationConfiguration(required: Boolean) = LocationConfiguration( + required = required, + intervalMillis = 60_000, + minimumIntervalMillis = 30_000, + maximumBatchDelayMillis = 0, + minimumDisplacementMillimeters = 0, + priority = LocationPriority.BALANCED, + ) + private fun verified(configuration: StudyConfiguration) = VerifiedConfiguration( configuration = configuration, canonicalConfigurationBytes = byteArrayOf(1), @@ -1060,3 +3022,5 @@ class StudySessionManagerTest { } private const val RAW_PUBLIC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +private const val NANOS_PER_HOUR = 60L * 60L * 1_000_000_000L +private const val NANOS_PER_MILLISECOND = 1_000_000L diff --git a/docs/data-collector-implementation-guide.md b/docs/data-collector-implementation-guide.md index ef57a70..b4e049a 100644 --- a/docs/data-collector-implementation-guide.md +++ b/docs/data-collector-implementation-guide.md @@ -43,8 +43,6 @@ projection. `LatestValueRateGate.kt` is the tested rate gate that on-change coll interface CollectorPlugin { val descriptor: CollectorDescriptor - fun accessRequirements(configuration: CollectorConfiguration): Set - fun create(configuration: CollectorConfiguration, context: CollectorContext): Collector } @@ -61,9 +59,10 @@ interface Collector { } ``` -`accessRequirements` and `create` both receive the base `CollectorConfiguration` interface -and must narrow it themselves. Every existing plugin rejects a mismatch with -`IllegalArgumentException` rather than substituting a default. +`create` receives the base `CollectorConfiguration` interface and must narrow it itself. Every +existing plugin rejects a mismatch with `IllegalArgumentException` rather than substituting a +default. Access is not a plugin callback: the descriptor declares a closed, configuration-independent +set of capabilities, and the registry derives requirements from it. ### Descriptor @@ -73,6 +72,7 @@ data class CollectorDescriptor( val displayName: String, val privacyClass: PrivacyClass, val eventContract: CollectorEventContract, + val accessKinds: Set, ) { val payloadSchemaVersion get() = eventContract.payloadSchemaVersion val maximumEncodedEventBytes get() = eventContract.maximumEncodedEventBytes @@ -82,6 +82,9 @@ data class CollectorDescriptor( require(displayName.isNotBlank()) { "Collector display name must not be blank" } } + fun accessRequirements(required: Boolean): Set = + accessKinds.mapTo(mutableSetOf()) { kind -> AccessRequirement(kind, required) } + private companion object { val ID_PATTERN = Regex("[a-z][a-z0-9_.-]{2,63}") } @@ -96,6 +99,9 @@ enum class PrivacyClass { `CollectorEventContract` supplies the closed payload-type/field set, field types and bounds, payload schema version, and maximum encoded size. Plugins obtain it from `ProtocolEventContracts[ID]`; editing the generated file directly is forbidden. +`accessKinds` is the complete Android capability declaration for this collector. It cannot vary +with a researcher's parameters and carries no permission string, `Intent`, participant-facing text, +or callback. ### Context and clocks @@ -128,8 +134,9 @@ data class ResearchTime( Wall time can jump backwards when the participant or the network changes the device clock. `elapsedRealtimeNanos` is monotonic within a boot, and `bootSessionId` tells an analyst -which boot an elapsed reading belongs to. The admission gate compares only -`elapsedRealtimeNanos`, so ordering decisions never depend on wall time. +which boot an elapsed reading belongs to. Admission requires the same boot as the one durable +`PARTICIPANT_STARTED` transition and compares that monotonic reading against both the current epoch +and the exact signed duration; ordering and deadline decisions never depend on wall time. ### Event sink @@ -177,6 +184,7 @@ timestamp it resumes from, even after the segment holding that event is gone. ```kotlin enum class AccessKind { FINE_LOCATION, + LOCATION_SERVICES, BACKGROUND_LOCATION, NOTIFICATIONS, USAGE_ACCESS, @@ -195,15 +203,71 @@ data class AccessRequirement( ``` `AccessKind` deliberately mixes Android runtime permissions, special access grants, an -input-method selection state, and hardware capabilities. They are all preconditions the -participant can see and, except for hardware, revoke. `required` is not a property of the -collector. It is copied from the `required` flag the researcher set on that collector in the -study configuration. - -| `required` | Missing access at preflight | Missing access at start | -| --- | --- | --- | -| `true` | `completeAccessSetup` rejects the command; the study cannot reach `READY` | collector health becomes `BLOCKED_ACCESS` / `ACCESS_UNAVAILABLE` | -| `false` | preflight passes | collector health becomes `BLOCKED_ACCESS` / `ACCESS_UNAVAILABLE`, the rest of the study runs | +input-method selection state, and hardware capabilities. They are all participant-visible +preconditions and, except for hardware, can change after setup. A descriptor names only the kinds. +`CollectorRegistry.accessRequirements` combines each configured collector's `required` flag with +its descriptor's `accessKinds`, returning `CollectorAccessRequirement` values that retain the +collector ID as owner. + +`StudyAccessPolicy` then adds `NOTIFICATIONS` as an unconditional required study-feature owner and +deduplicates by `AccessKind` without discarding owners. The merged requirement is required when any +owner is required. For example, required Network usage plus optional Usage events yields one +required Usage Access item whose owner list preserves both collector IDs and marks the latter +optional. Notification access remains required even when the study has no interventions. + +| Requirement | Missing at Done | Removed before Start/Resume | Removed while running | +| --- | --- | --- | --- | +| At least one owner is required | `completeAccessSetup` re-inspects and rejects; state remains `ACCESS_SETUP` | Start/Resume re-inspects and rejects; state remains `READY`/`PAUSED` | The service waits 25 seconds before reconciling; an exact location probe has a five-second deadline, giving a nominal 30-second code-path budget rather than a wall-clock SLA. The study gate closes, a durable `REQUIRED_ACCESS_MISSING` pause begins, and persistence failure never reopens admission. | +| Every collector owner is optional | Setup may complete | That collector starts/resumes `BLOCKED_ACCESS` / `ACCESS_UNAVAILABLE`; other collectors run | Only each affected collector's gate closes before its source pauses. Other collector gates stay open, and the affected gate reopens only after a successful start or resume. | + +Start and Resume first ask the foreground-service host to acknowledge Android's notification and +exact service types, with a five-second timeout. The runtime does not start or resume any collector +until that acknowledgement succeeds. If Android redelivers an old service intent into a new process, +the service first shows a short-lived neutral restoration notification using only `specialUse` and +no study title. The initialized session then revalidates durable `RUNNING` state and current access +and either replaces it through a fresh, acknowledged start with the exact types or removes it while +stopping the stale service. + +Whole-study safety reconciliation closes every admission gate, then writes an identity-free typed +marker to app-private no-backup storage. When the durable state is `RUNNING`, it persists the +matching `PAUSED` transition; containment from `READY` or an already `PAUSED` state preserves that +existing lifecycle boundary and uses the marker only for the outstanding cleanup obligation. +Required-access loss records `REQUIRED_ACCESS_MISSING`; losing every acknowledged foreground host +during an in-run type change records `COLLECTION_HOST_FAILURE`; a failed or cancelled source release +records `COLLECTION_TEARDOWN_FAILURE`; and an untrustworthy mutable store operation records +`STORAGE_FAILURE`. Failure to durably establish or retire the study's WorkManager set records +`WORK_SCHEDULING_FAILURE`. These five names are safety-pause/transition reasons; fixed UI incidents, +collector-health reason codes, and upload-failure codes are separate taxonomies. Runtime-owned +failures synchronously persist the safety witness before returning. If +persistence, collector teardown, or host cleanup does not complete, unique WorkManager work carries +the same reason and retries independently of the foreground service. Enqueue is a durable handoff +only after WorkManager acknowledges its database operation. Recovery, Start, Resume, and running +reconciliation merge the marker and active retry before any gate opens and reject a conflict or +inspection failure. After the durable transition and cleanup succeed, a non-cancellable completion +sequence clears the marker, awaits retry cancellation, and only then clears in-memory pending state. + +`AccessRules` in `:core:access` is the closed Android acquisition contract. Every `AccessKind` has +exactly one rule with a stable order, prerequisites, an optional `SetupAction`, and optional +`SetupGuidance`. Fine location precedes request-specific Android location-service readiness, which +precedes background location; App details opens only after both prerequisites are satisfied. +Research-keyboard Enable precedes Select. Runtime +permissions, system settings, and the input-method picker are closed `SetupAction` variants; +hardware has no action. A missing system settings handler becomes an explicit unavailable result, +not a different intent or fallback. + +`AccessInspectionRequest` keeps platform inspection typed and complete: requirements, a +`LocationAccessProfile` copied from the signed `LocationConfiguration`, and closed notification +purposes. It never accepts a channel ID or arbitrary intent from a collector or configuration. +`StudyAccessGateway.inspect` is suspendable because the production location probe calls +`SettingsClient.checkLocationSettings` with the same priority, interval, minimum interval, maximum +batch delay, and minimum displacement as `LocationCollector`. Global location off and +`RESOLUTION_REQUIRED` stay actionable through the fixed location-settings screen; +`SETTINGS_CHANGE_UNAVAILABLE` and an unclassified check failure are explicit fail-closed +unavailable states. + +The app renders the resolved plan as one card per kind, including every owner, app-authored English +and Traditional Chinese manual steps, and one explicit action button where applicable. A plugin or +signed configuration cannot inject a permission, intent, action callback, or setup string. A blocked collector produces no events. It never produces substitute, degraded, or placeholder events. @@ -325,15 +389,22 @@ equally to a configuration built in a test. The base class for callback-driven collectors is [`SerializedCallbackCollector`](../core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/SerializedCallbackCollector.kt). -It marks all four lifecycle methods `final` and leaves you two: +It marks all five lifecycle methods `final`, leaves two required source hooks, and provides one +optional post-admission hook: ```kotlin protected abstract suspend fun registerSource(): SourceRegistrationResult +protected open suspend fun onSourceAdmitted() = Unit protected abstract suspend fun unregisterSource(): SourceTeardownResult ``` -Both return an explicit outcome rather than `Unit`, because a failure has to say whether the -Android source was left attached. `SourceRegistrationResult` is `Registered`, `Released(failure)` +`registerSource()` owns only physical source registration. Override `onSourceAdmitted()` when the +collector must publish one initial snapshot: the runtime calls it only after registration succeeds +and that collector's admission gate is open. A callback delivered during registration is outside +the admitted interval and must not be used as the only initial-state record. + +The two required source hooks return an explicit outcome rather than `Unit`, because a failure has +to say whether the Android source was left attached. `SourceRegistrationResult` is `Registered`, `Released(failure)` when rollback proved nothing is attached, or `Uncertain(failure)` when it did not. `SourceTeardownResult` is `Released` or `ReleasedWithFailure(failure)`. Both promise the callbacks are physically released or independently isolated. Throwing instead leaves the source uncertain, @@ -359,6 +430,10 @@ Use it unless your source is a periodic query. What the base class does with eac the job only when the source is proven released. That is also the only case in which the collector can be started again afterwards: an `Uncertain` registration leaves the consumer running and blocks a restart. +5. After `start()` returns successfully, the runtime opens the collector gate and invokes + `onAdmissionOpened()`, which validates the registered source and delegates to + `onSourceAdmitted()`. If that hook fails, the runtime closes the gate and reports + `COLLECTOR_START_FAILED`; the owned source remains available for explicit teardown. ### `pause()` @@ -367,22 +442,31 @@ Use it unless your source is a periodic query. What the base class does with eac reaches `emit` before `pause` returns. 3. Sets `PAUSED` unless health is already `FAILED`. A failure is never cleared by pausing. -Around this, the runtime has already put the admission gate into `DRAINING` with a boundary -taken from `clocks.now().elapsedRealtimeNanos`. During the drain, only events from the same -epoch whose `observedTime.elapsedRealtimeNanos` is strictly before the boundary are accepted. -Anything observed after the participant pressed pause is dropped, even if it is still sitting -in the queue. +Around a participant pause, the runtime puts the global study gate into `DRAINING` with a boundary +taken from `clocks.now().elapsedRealtimeNanos`. During the drain, only events from the same study and collector epochs whose +`observedTime.elapsedRealtimeNanos` is strictly before the boundary are accepted. Each collector gate +closes after its source teardown; the runtime waits for every already-admitted write before persisting +the participant transition. Anything observed after the participant pressed pause is dropped, even +if it is still sitting in the queue. Required-access and other whole-study safety loss instead +force-close all gates immediately, then waits for any write already executing in the store. + +Optional-access loss uses a narrower ordering: close the affected collector gate first, then pause +that source. It does not drain or close the study gate or any unrelated collector gate. A teardown +failure therefore cannot leave the affected source able to persist events. ### `resume()` 1. Requires an existing consumer job and a `PAUSED` or `FAILED` status. 2. Calls `registerSource()` again. It does not launch a second consumer. 3. Sets `ACTIVE`. +4. After `resume()` succeeds, the runtime opens the new collector epoch and calls the same + post-admission hook. Hook failure closes the epoch and reports `COLLECTOR_RESUME_FAILED`. -The runtime calls `admissionGate.open()` on resume, which increments the epoch. Tokens -captured before the pause are dead. A retrospective-query collector must start a new coverage -window at resume time, and must not backfill the paused interval. See `network_usage.v1` and -`usage_events.v1`, both of which reset their query start to the resume wall time. +The runtime opens a new study epoch on resume, but each collector gate stays closed until that +collector's `start()` or `resume()` returns successfully. Both generations change, so tokens captured +before the pause are dead. A retrospective-query collector must start a new coverage window at resume +time, and must not backfill the paused interval. See `network_usage.v1` and `usage_events.v1`, both of +which reset their query start to the resume wall time. ### `stop()` @@ -409,7 +493,8 @@ protected fun capture(draft: () -> EventDraft) { } ``` -The token is taken *before* the draft is built, on the source thread. Two consequences you +The collector-bound `EventSink` captures a composite token from the study-wide gate and that +collector's private gate *before* the draft is built, on the source thread. Three consequences you should rely on: - If the study is not running, the lambda never executes. No observation is even constructed @@ -417,6 +502,12 @@ should rely on: asserts exactly this. - The token pins the epoch at observation time, not at write time. An event queued before a pause carries the pre-pause epoch and is judged against the drain boundary. +- The original observation time must be from the participant-start boot and strictly before the + signed monotonic deadline. The runtime checks this when registering the admitted write and again + inside the metadata boundary, so a delayed callback or delayed WorkManager completion cannot add + data beyond the declared duration. +- Optional access can close one collector's gate without interrupting other collectors; old tokens + from that collector stay invalid after access returns. A collector subclass supplies only the draft: @@ -476,11 +567,16 @@ declared ID, payload schema, payload type, field set, field values, and worst-ca before it consults the admission gate. It then returns without recording an incident or closing the gate. Nothing about it improves on a retry. -`StorageFailure` is not recoverable by retrying. On the runtime side, `emit` force-closes the -admission gate, records the `STORAGE_WRITE_FAILED` incident, and launches a fail-closed -transition to `PAUSED` with reason `STORAGE_FAILURE`. The design choice is deliberate: -when the system can no longer prove it is recording completely, it stops recording rather -than producing a log with invisible holes. +`StorageFailure` is not recoverable by retrying the event. While the append still owns the metadata +serialization lock, `emit` force-closes the study gate and every collector gate, records the +`STORAGE_WRITE_FAILED` incident, and latches a typed `STORAGE_FAILURE` safety-pause request. Before +the failing append returns, the app-owned `SafetyPauseWitness` must persist the private marker or +receive WorkManager's durable enqueue acknowledgement for a reason-bearing retry. The session layer +does not acknowledge that request until it has either persisted `PAUSED` and completed cleanup or +confirmed that durable retry. If +both paths fail, the request remains pending and closed for another attempt. The design choice is +deliberate: when the system can no longer prove it is recording completely, it stops recording +rather than producing a log with invisible holes. ## 8. Concurrency and backpressure @@ -675,9 +771,11 @@ cross-device precision or presence claim is made. { "include_bandwidth_estimates": true } ``` -Uses `ConnectivityManager.registerDefaultNetworkCallback`. Every `registerSource()` — so at -both start and resume — also writes one `NETWORK_SNAPSHOT` describing the current state. A -segment therefore never begins with an unknown connection state. +Uses `ConnectivityManager.registerDefaultNetworkCallback`. After every successful source +registration — at both start and resume — `onSourceAdmitted()` writes one `NETWORK_SNAPSHOT` +only after the collector gate is open. The admitted interval therefore always contains a current +snapshot, and that snapshot cannot be dropped at the activation boundary. A platform callback may +race it into the queue, so the snapshot is not promised to be the segment's first event. Payload types: `NETWORK_AVAILABLE`, `NETWORK_LOST`, `NETWORK_CAPABILITIES`, `NETWORK_SNAPSHOT`. @@ -780,7 +878,7 @@ used and is sensitive; a data set containing it is not anonymous. | Display name | `Location` | | Privacy class | `SENSITIVE` | | Declared max bytes | 4,096 | -| Access | `FINE_LOCATION` and `BACKGROUND_LOCATION` | +| Access | `FINE_LOCATION`, `LOCATION_SERVICES`, and `BACKGROUND_LOCATION` | | Queue | 512 | Configuration fields, all required and exact: @@ -799,6 +897,23 @@ register rather than silently switching to a different, undocumented source. `registerSource()` re-checks `ACCESS_FINE_LOCATION` itself and throws if it was revoked after the study started. +Before registration, the application derives a `LocationAccessProfile` from these same five +configuration fields and asks Play services whether the exact request can be satisfied. The +foreground-service monitor waits 25 seconds between complete access inspections, and the exact +location-settings probe has a five-second deadline. The nominal code-path budget from a completed +check to a decision is therefore 30 seconds; Android scheduling can extend the wall-clock interval. +Turning off location from Quick Settings pauses a required study or blocks an optional Location +collector instead of leaving its health at `ACTIVE` while fixes stop. + +Foreground-service typing follows the same fail-closed boundary. When optional Location access +returns, the application first obtains an acknowledged host start with the `location` type and only +then starts or resumes the collector. On revocation, it first closes the Location collector's private +gate and pauses that source, and only then starts the host again without `location` (leaving +`specialUse`). A failed type upgrade keeps Location admission closed while unrelated collectors +continue only when the fallback non-location host is acknowledged. A failed upgrade plus failed +fallback, or a failed downgrade, closes all collector admission and durably pauses the study with +`COLLECTION_HOST_FAILURE`. + `LOCATION_FIX` fields: `source_elapsed_realtime_nanos`, `source_time_utc_millis`, `latitude_degrees`, `longitude_degrees`, `horizontal_accuracy_meters`, `mock`; plus `altitude_meters`, `vertical_accuracy_meters`, `speed_meters_per_second`, @@ -897,19 +1012,31 @@ canonical round-trip. ### Step 4 — access kind -`AMBIENT_LIGHT_HARDWARE` is a closed [`AccessKind`](../core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/CollectorContracts.kt). -Every exhaustive `when` and the app build fail until the following participant-facing surfaces -agree: +`AmbientLightCollectorPlugin.descriptor.accessKinds` contains `AMBIENT_LIGHT_HARDWARE`, a closed +[`AccessKind`](../core/collector-api/src/main/kotlin/cool/jacoblin/particeps/core/collector/CollectorContracts.kt). +The plugin does not implement an access callback. `CollectorRegistry` derives the owned +requirement from that descriptor and the configuration's `required` flag. + +Adding a genuinely new `AccessKind` is a cross-layer contract change. Every exhaustive `when` and +the app build must fail until these closed surfaces agree: | File | What to add | | --- | --- | -| `core/access/.../AccessManager.kt` → `isGranted` | `getDefaultSensor(Sensor.TYPE_LIGHT) != null` | -| `core/access/.../AccessManager.kt` → `settingsIntent` | `null` — there is no settings screen for hardware | -| `app/.../MainActivity.kt` → `requestAccess` | `Unit` — hardware cannot be requested | -| `app/.../CollectorDashboard.kt` → `AccessKind.labelRes()` | a participant-readable label as an app string resource | +| The collector's `CollectorDescriptor.accessKinds` | The capability, and no permission string, `Intent`, text, or callback | +| `core/access/.../AccessRules.kt` | One semantic order, prerequisite set, closed action, and guidance choice; hardware uses no action | +| `core/access/.../AccessManager.kt` | The authoritative Android state check; request-specific state uses a typed suspend probe, while ambient light uses `getDefaultSensor(Sensor.TYPE_LIGHT) != null` | +| `app/.../AccessPresentation.kt` | Exhaustive localized label, plus exhaustive guidance presentation when the rule declares guidance | +| `app/src/main/res/values*/strings.xml` | Participant-readable English and Traditional Chinese labels and any app-authored manual steps | +| `core/access/.../AccessRulesTest.kt` and access UI tests | Closed-rule coverage, prerequisite/action resolution, owners, manual guidance, and absence of fallback | + +Do not add per-kind arbitrary-intent handling to `MainActivity`. It dispatches only the closed +`SetupAction` variants: foreground location or notification runtime permission, one of the fixed +system settings actions, or the input-method picker. Background location deliberately resolves to +the fixed App details action after fine location and request-specific location-service readiness; +it is never requested as another runtime permission. Required missing hardware blocks enrollment. Optional missing hardware reports blocked access and -starts only if hardware becomes available; it never substitutes another source. +does not block the rest of the study; it never substitutes another source. ### Step 5 — catalog and generated contract @@ -979,14 +1106,29 @@ allowlist, and catalog parity checks must all land together. - [ ] Strict configuration tests pass for nominal values, both boundaries, out-of-range values, unknown keys, missing keys, wrong types, and canonical round-trip. -- [ ] `required` and optional access both behave as documented, and a blocked collector - produces no events rather than substitutes. -- [ ] Start, pause, resume, stop, repeated pause cycles, process restart, and mid-study - permission revocation are all exercised. -- [ ] No event is recorded after the pause boundary, and a token from a previous epoch is - rejected. +- [ ] Descriptor `accessKinds`, registry owner preservation, shared-kind deduplication, and + required/optional merging all behave as documented; Notifications remains unconditionally + required, and a blocked collector produces no events rather than substitutes. +- [ ] Every `AccessKind` has one closed rule and exhaustive English/Traditional Chinese + presentation; prerequisite order, explicit action dispatch, manual guidance, and unavailable + system/hardware states are tested without a fallback. +- [ ] Location access passes the exact signed request through `AccessInspectionRequest` and tests + ready, resolution-required, change-unavailable, and check-failed results; notification tests + cover base channels with and without the intervention feature. +- [ ] Start, resume, and recovered `RUNNING` activation wait for an acknowledged foreground-service + type; timeout and redelivered-intent restoration/stop paths are exercised. +- [ ] Pause, stop, repeated pause cycles, process restart, and mid-study permission revocation are + exercised, including the 25-second monitor, five-second location-probe deadline, durable + typed safety-pause marker, reason-bearing WorkManager retry, acknowledged enqueue/cancellation, + worker-only process recovery, and Resume waiting for retry retirement before gates reopen. +- [ ] No event is recorded after a whole-study pause boundary or an optional collector's gate closes; + old study and collector epoch tokens are rejected. Optional Location tests prove host type + upgrade-before-resume and gate/pause-before-downgrade ordering, successful fallback continuity, + and global `COLLECTION_HOST_FAILURE` pause after double-promotion or demotion failure. - [ ] Queue full, disk full, and AEAD or key failure all fail closed, and no payload value - reaches logcat. + reaches logcat. An append plus metadata/marker failure must still leave one acknowledged typed + `STORAGE_FAILURE` work record that prevents process-death recovery from starting a host or + collector. - [ ] Every payload field's unit, clock, precision, platform limitation, and sensitivity is documented. - [ ] The catalog validates, generated Kotlin is current, schema-invalid and over-size events are diff --git a/docs/data-dictionary.md b/docs/data-dictionary.md index dacf68c..1c2707d 100644 --- a/docs/data-dictionary.md +++ b/docs/data-dictionary.md @@ -109,7 +109,17 @@ A state is one of `IMPORTED`, `CONFIG_VERIFIED`, `CONSENT_PENDING`, `ACCESS_SETU | `PARTICIPANT_FINISHED_EARLY` | `COMPLETED` | | `STUDY_DURATION_ELAPSED` | `COMPLETED` | | `PARTICIPANT_WITHDREW` | `WITHDRAWN` | +| `COLLECTION_HOST_FAILURE` | `PAUSED` | +| `COLLECTION_TEARDOWN_FAILURE` | `PAUSED` | +| `REQUIRED_ACCESS_MISSING` | `PAUSED` | | `STORAGE_FAILURE` | `PAUSED` | +| `WORK_SCHEDULING_FAILURE` | `PAUSED` | + +The last five rows are lifecycle transition reasons, not generic error labels. They appear in an +exported history only when an already durable `RUNNING` study is forced to `PAUSED`. Missing access +during setup, first Start, or Resume leaves the existing state unchanged and therefore adds no such +transition. Collector-health codes, upload-failure codes, and participant-facing incident codes are +separate diagnostic fields and must not be interpreted as `transitions[].reason` values. The history is checked before any plaintext is published: the first `from` is `IMPORTED`, each `from` equals the previous `to`, each `reason` agrees with its destination, the pair is a legal transition, and the last `to` equals `state`. A bundle whose history does not chain fails verification rather than decoding partially. @@ -500,7 +510,10 @@ Fused Location fixes via Google Play Services. This collector always requires precise location. There is no coarse-only mode, and `priority` selects a power/accuracy trade-off within fine location rather than reducing the permission it needs. Say so in your consent text. -**Access:** fine location and background location. Continuous collection runs under a visible foreground service notification. +**Access:** fine location, request-specific Android location-service readiness, and background +location. Before registration the app asks Play services whether the exact configured priority, +intervals, batching, and minimum distance can be satisfied. Continuous collection runs under a +visible foreground service notification. **Payload type:** `LOCATION_FIX`. @@ -580,7 +593,7 @@ In a study that uploads, a confirmed delivery lets the device reclaim space. Abo Study metadata is held separately from the events. It is capped at 1 MiB and kept outside the event budget by a 2 MiB reserve. The record of what a study is, and how far it has been delivered, therefore cannot be crowded out by the events it describes. Its container header is `PTCMET01`. -Normal study opening does not decrypt its event log. Framing and sequence contiguity are checked from plaintext frame headers, and each collector's most recent event is persisted in metadata rather than recovered by scanning. Start-up cost is therefore linear in frames rather than in bytes decrypted. The sole exception is a one-boundary-ahead append journal with a durable tail: recovery authenticates exactly that tail before applying its metadata. All other event payloads are authenticated when read, so damage outside the recovery tail surfaces at export or upload rather than at launch. +Normal study opening does not decrypt its event log. Framing and sequence contiguity are checked from plaintext frame headers, and each collector's most recent event is persisted in metadata rather than recovered by scanning. Start-up cost is therefore linear in frames rather than in bytes decrypted. The sole exception is a fail-closed append journal whose proposed event is the durable tail: recovery authenticates exactly that tail, retains it inside PAUSED metadata, and keeps the journal until the app-owned winning safety reason is resolved. If the event is absent or truncated, recovery instead retains the prior event boundary PAUSED. All other event payloads are authenticated when read, so damage outside the recovery tail surfaces at export or upload rather than at launch. Each collector descriptor declares `maximumEncodedEventBytes`. The runtime encodes every admitted event with the worst-case sequence width and rejects it before append when it exceeds that diff --git a/docs/maintainers/release.md b/docs/maintainers/release.md index 8093c00..29b8609 100644 --- a/docs/maintainers/release.md +++ b/docs/maintainers/release.md @@ -8,11 +8,28 @@ All workflows live in [`.github/workflows`](../../.github/workflows). The two th **`Android CI`** (`ci.yml`) runs on pushes to `main`, on pull requests, and on manual dispatch. It runs unit tests, Android lint, Protocol/catalog conformance, Collector capability checks, and debug -and release builds. Successful runs retain the debug APK as an artifact for 14 days. +and release builds. A dependent job then runs the complete connected suite on an API 34 Google APIs +emulator. Successful runs retain the debug APK as an artifact for 14 days. **`Android Release`** (`release.yml`) accepts only `v` tags that are reachable from `main` — for example `v0.1.0`. A tag with a prerelease suffix produces a GitHub prerelease. -The release workflow reconstructs the same `.signing` configuration used locally and re-runs tests and lint. It writes the tag into `versionName` and the workflow run number into `versionCode`, then verifies the APK that Gradle signed with `apksigner verify`. The GitHub release carries both the APK and its SHA-256 checksum. Any test, signing, or verification failure stops the release; nothing is published. +The release workflow first runs the complete connected suite on an API 34 Google APIs emulator. Only +after that gate passes does the dependent release job reconstruct the same `.signing` configuration +used locally and re-run host-side tests plus debug and release lint. It writes the tag into +`versionName` and the workflow run number into `versionCode`, then verifies the APK that Gradle signed +with `apksigner verify`. Verification must report exactly one signer certificate and its SHA-256 must +match the checked-in production identity anchor; printing certificate details is not sufficient. The +GitHub release carries both the APK and its SHA-256 checksum. Any device test, host-side test, +signing, identity, or verification failure stops the release; nothing is published and signing +secrets are not materialized before the device gate succeeds. + +Permissions are job-scoped. The instrumented gate, including the third-party emulator action, gets +only `contents: read`; only the dependent release job gets `contents: write` to create or update the +GitHub Release. + +Particeps is distributed directly as that signed APK. A Play listing, Play release track, and AAB +are not prerequisites for this release process; do not replace the verified APK artifact with an +unsigned APK or an unrelated bundle build. ## The published site @@ -39,36 +56,57 @@ Two unrelated keys are involved, and they must never be interchanged. Losing the Android signing private key means no future build can update a directly installed app under the same identity. Keep an offline, encrypted backup. -### No release so far can be updated in place +The sole repository source of truth for the production Android certificate identity is +[`.github/android-release-signing-certificate.sha256`](../../.github/android-release-signing-certificate.sha256). +It is the lowercase SHA-256 of the rc.5 signing **certificate**, not the APK checksum, keystore +checksum, or public-key digest. `tools/verify_release_apk.py` requires exactly one APK signer and +compares its certificate digest with that anchor. Do not change the anchor to make a mismatched build +pass: a deliberate certificate rotation breaks direct update continuity and requires an explicit new +application-distribution plan. -No release published so far can be updated in place. The application ID moved twice and the release -signing key was rotated, and a device accepts an update only when both are unchanged. -[CHANGELOG.md](../../CHANGELOG.md) records which release carries which application ID, and states -that consequence once for every reader. +### Update compatibility + +`v1.0.0-rc.5` established the current `cool.jacoblin.particeps` application ID and the certificate in +the repository identity anchor. `v1.0.0-rc.6` keeps both, so it updates rc.5 in place. Earlier +candidates used another application ID, another certificate, or incompatible file identities and +cannot update directly to the current build. [CHANGELOG.md](../../CHANGELOG.md) records the action +required from each release. The key was rotated to correct the certificate's subject, which named the pre-rename product. A certificate is signed over its own subject, so changing it means issuing a new one. That was -affordable only because every tag published to that point was a pre-1.0 release candidate and -Developer Verification had not yet been registered. It stops being affordable the moment a -participant is running a released build, so it does not happen again. +affordable only before the current identity and key were handed to testers. It stops being +affordable once a participant is running rc.5 or later, so it does not happen again. -Say this in the release notes. A tester expecting an in-place update reads a correct install as a -failed one. The notes for `v1.0.0-rc.4` do not say it, so it still has to reach testers another -way. +State update compatibility in every release note. In particular, rc.5 updates in place to rc.6; +rc.4 and earlier do not. ## Android Developer Verification -Google's Developer Verification binds a verified developer identity to the package names that developer distributes and the certificates those packages are signed with. Registration is per package name, and no name this project has used was ever registered. `cool.jacoblin.particeps` therefore needs its own entry from scratch. Register it against the fingerprint of the **current** keystore — the rotation above means any fingerprint recorded before it is wrong. +The maintainer has confirmed that the developer identity in Android Developer Console is verified. +That is distinct from proving that a particular package name and signing certificate are registered: +this repository contains no authoritative evidence of the package-registration status. Before +relying on Developer Verification for distribution, check the console entry for +`cool.jacoblin.particeps` and confirm that it carries the fingerprint of the **current** keystore. +Register the package/certificate pair there if it is absent; do not infer this step from identity +verification alone. ```bash -apksigner verify --print-certs app/build/outputs/apk/release/app-release.apk +python3 tools/verify_release_apk.py \ + "$ANDROID_SDK_ROOT/build-tools/37.0.0/apksigner" \ + app/build/outputs/apk/release/app-release.apk ``` -The programme's requirements and deadlines are Google's and change; check the current rules when a release actually depends on them rather than trusting this paragraph. +Developer Verification does not make Google Play the distribution channel. This project continues +to publish its signed APK directly. The programme's requirements and deadlines are Google's and +change; check the current rules when a release actually depends on them rather than trusting this +paragraph. ## Local signing material -`assembleRelease` produces a signed APK when `.signing/release-signing.properties` exists locally, and an unsigned release APK otherwise. The whole `.signing/` directory is git-ignored and must stay that way. +`assembleRelease` produces the direct-distribution signed APK when +`.signing/release-signing.properties` exists locally, and an unsigned release APK otherwise. The +whole `.signing/` directory is git-ignored and must stay that way. This process does not require +`bundleRelease` or an AAB. ## GitHub secrets @@ -79,6 +117,9 @@ Create four repository secrets under Settings → Secrets and variables → Acti - `ANDROID_KEY_ALIAS` - `ANDROID_KEY_PASSWORD` +The keystore and alias selected by these secrets must produce the anchored certificate. The workflow +rejects an otherwise valid APK when it is signed by any other certificate or by multiple signers. + On macOS you can pipe the keystore straight to the GitHub CLI without leaving a copy in the working directory: ```bash @@ -96,7 +137,12 @@ git tag -a v0.1.0 -m "v0.1.0" git push origin v0.1.0 ``` -Before tagging, update `version` and `date-released` in [`CITATION.cff`](../../CITATION.cff). Both fields are present again and name `1.0.0-rc.4`, the first post-rename tag. They were absent before it because every tag up to `v1.0.0-rc.3` carries the old identity. The file named no version rather than attributing one of those releases to Particeps. Keep both fields in step with the tag at every release. +Before tagging, update `version` and `date-released` in +[`CITATION.cff`](../../CITATION.cff). Keep both fields in step with the tag at every release. +Add exactly one non-empty `## — YYYY-MM-DD` section to +[`CHANGELOG.md`](../../CHANGELOG.md), including explicit update or fresh-install instructions. The +workflow extracts that exact section as the GitHub Release notes and fails before publishing if the +section is missing, duplicated, malformed, or empty; it does not substitute generated notes. ### One-off: the Particeps cutover @@ -108,8 +154,9 @@ The rename is not a recurring step, and it is not finished when the code lands e What remains: -- Register `cool.jacoblin.particeps` under Developer Verification against the current keystore fingerprint, as above. -- The release notes for `v1.0.0-rc.4` are the generated changelog and do not say that it is a fresh install rather than an update. Say it to testers by some other route, and in the notes of the next tag. +- Confirm the `cool.jacoblin.particeps` package/certificate entry in Android Developer Console as + described above. Developer identity verification is complete; package registration is not marked + complete without console evidence. - Reissue any join link or QR that pointed at the old Pages path, as the section above describes. This is per study rather than a single step: it is finished only when no issued link and no printed QR still points there. ## Pinned signers diff --git a/docs/participant-guide.md b/docs/participant-guide.md index 3169fa1..41a7629 100644 --- a/docs/participant-guide.md +++ b/docs/participant-guide.md @@ -4,7 +4,16 @@ Particeps is a research data collection app for Android. It collects and stores The name is a Latin word. It means someone who takes part. That is meant concretely here: your data stays on the phone, every source a study uses is shown to you before you are asked to consent, and nothing at all is collected until you press Start study. -It does not mean the study is yours to design. Which sources it may use, how long it runs, and whether it sends data to the research team automatically are fixed in the signed file you import. Nothing you do in the app changes them. You can read all of that before you agree, and you can say no to the whole study. Within a study, the only sources you can hold back are the ones it marks optional. Declining the Android access an optional source needs, or not enabling the research keyboard, leaves that source off, and the study runs without it. Section 4 covers what each source asks for and section 5 covers the keyboard. A source the study marks required stops the study instead of running without it. Data that has already reached the research team cannot be taken back. +It does not mean the study is yours to design. Which sources it may use, how long it runs, and +whether it sends data to the research team automatically are fixed in the signed file you import. +Nothing you do in the app changes them. You can read all of that before you agree, and you can say +no to the whole study. A source marked Optional can stay off when you leave its unshared Android +access disabled. Optional is not a separate source switch: if a required source uses the same +Android capability, granting it for the required source also makes it available to the optional +one. The Access card names every source that shares a capability. Section 4 covers those cards and +section 5 covers the keyboard. A source the study marks required prevents setup, starting, or +resuming while its access is missing instead of being silently ignored. Data that has already +reached the research team cannot be taken back. What you can always do is decline, pause, finish early, withdraw, and — once you have finished or withdrawn — permanently delete the study data on your phone. The app never asks why. @@ -24,14 +33,18 @@ Because English is the default, this guide quotes the screen in English and give **Ordinary study prose is never translated.** The study title, purpose, contact details, and consent text are shown exactly as signed. Survey titles, descriptions, questions, and choices are different. The signed configuration can carry an English and a Traditional Chinese version. The survey uses the best exact language match, and falls back to the signed default. -Two pieces of text stay in English whichever language you pick, because they are written into the code rather than into the translated set: - -- The ongoing notification shown while a study is collecting: "Research collection active", followed by the study title. -- The banner on the research keyboard: "Research touch capture active" or "Touch capture disabled for this field". +The ongoing collection notification and the research keyboard's status banner follow the app +language. Researcher-authored study text still remains exactly as signed, including the study title +shown after that localized notification heading. The interface also says some things without words: a check mark, a coloured dot, a row of five dots, a progress bar, a set of small drawn icons. The app draws all of these itself rather than taking them from an icon set, so this guide describes what each one looks like, and you can match it against the screen in front of you. -Every word of the interface lives in [`app/src/main/res/values/strings.xml`](../app/src/main/res/values/strings.xml) (English) and [`app/src/main/res/values-zh-rTW/strings.xml`](../app/src/main/res/values-zh-rTW/strings.xml) (Traditional Chinese), so anyone can read those two files and check every label in this guide against them. +Participant-facing prose lives in Android string resources: the main app resources are +[`app/src/main/res/values/strings.xml`](../app/src/main/res/values/strings.xml) (English) and +[`app/src/main/res/values-zh-rTW/strings.xml`](../app/src/main/res/values-zh-rTW/strings.xml) +(Traditional Chinese), while a collector that owns its own participant surface keeps its strings in +that module's corresponding resource directories. Anyone can inspect those files and check every +label in this guide against them. ## What matters most @@ -67,7 +80,9 @@ The setup is five steps, and the screen shows **one** of them at a time. The hea 3. **Study.** The panel shows the study's purpose in the research team's own words, then three rows, each an icon and a value with no label. A head and shoulders is the research team's name, an envelope is their contact details, and a clock is how long the study runs. Read all four before you go on. Press Continue (繼續). 4. **Data.** Every source this study switched on, one row each, with a sentence saying what it records and a second line saying what it does not. Section 3 goes through them. Press Continue (繼續). 5. **Consent.** The research team's consent text, then two blocks the app writes itself: who signed the study, and whether it sends data automatically. Read all of it: the data, the purpose, the risks, the export, the withdrawal, and the deletion terms. Only if you understand it and want to take part, tick "I have read and agree to the data collection and export described above." (我已閱讀並同意上述資料收集與匯出方式。) and press Agree (同意). -6. **Access.** Grant what the study needs. Section 4 goes through it. Press Done (完成). +6. **Access.** Review one card for each Android capability the study needs. Every card says what + uses it; cards that need a system change provide a named button and, where Android requires + manual work, numbered instructions. Section 4 goes through them. Press Done (完成). 7. **Start.** Press Start study (開始研究). This is the press that begins collection; section 6 describes what happens next. At every one of these steps you can stop. Closing the app after importing leaves the study sitting there, unstarted, with nothing collected. Withdraw (退出研究) sits at the bottom of every panel except the Data one, so you can leave from almost anywhere in the setup without starting. @@ -190,7 +205,13 @@ The values written into those sentences: - **T** is an interval, written in the largest unit that stays exact: 30 s (30 秒), 15 min (15 分鐘), 2 h (2 小時), 1 day (1 天). - **D** is a distance in whole metres: 25 m (25 公尺). -Optional (選用) beside a name means the study can start without the Android access that source needs. If you leave that access off, that source alone reports `ACCESS_UNAVAILABLE` on the collecting screen and every other source carries on. Leaving one off is a normal choice, not an error. A source without that word needs its access granted before the study can start at all. +Optional (選用) beside a name means that source does not, by itself, make its Android access +mandatory. If no required source shares the same access, you can leave it off: that source alone +reports `ACCESS_UNAVAILABLE` on the collecting screen and every other source carries on. If an +optional and a required source share one Android capability, the Access step shows one shared card, +names both sources, and requires the capability because the required source needs it. A source +without Optional needs all of its access granted before the study can start. Optional is not a +separate on/off switch for a source: when its access is already granted, that source can run. The sentences are short because they are the summary. This is what each source actually puts in the file. The [data dictionary](data-dictionary.md) holds the exact field list for every source, including everything each one does not record; it is written against the same source code the app runs. @@ -213,50 +234,124 @@ The same names and the same icons come back on the collecting screen, one row pe ## 4. Access setup -You cannot start until every required item is granted. Optional items can be left off: the source that needs one reports `ACCESS_UNAVAILABLE` on the collecting screen, and the other sources keep working. +You cannot start until every required item is granted. Notifications are required for every study. +For collector access, an item is required when at least one required source needs it. An item used +only by optional sources can be left off: those sources report `ACCESS_UNAVAILABLE` on the +collecting screen, and the other sources keep working. -The access list is one row per item, separated by thin lines. Each row has a mark on the left, the name of the access, and sometimes a word on the right: +The Access step shows one card per Android capability, not one card per source. A shared capability +therefore appears once: for example, Data volume and App and screen use share a single Usage access +card. Each card shows the capability name, its status, and a **Used by** (使用這項設定) list naming +every source or study feature that depends on it. Optional (選用) beside an owner means that owner +does not make the shared capability mandatory. Optional beside the card title means that none of +its owners does. -| What the row shows | What it means | +| What the card shows | What it means | | --- | --- | -| A check mark | Granted | -| A solid red dot | Required, and not granted yet. You cannot start until it is | -| A hollow ring, with Optional (選用) at the end of the row | Optional, and not granted yet. You can start without it | - -Tap anywhere on a row that is not granted yet and the app sends you straight to the Android screen that grants it. Finish there, come back to the app, and the mark on that row changes. When no required row is left, press Done (完成). +| A check mark | Android currently reports the capability as granted or available | +| A solid red dot | Required, and not satisfied yet. You cannot finish setup, start, or resume until it is | +| A hollow ring, with Optional (選用) beside the title | Not satisfied, but every owner is optional, so the study can run without it | + +The whole card is not a control. When there is something you can do, use its explicit Allow +(允許), Open Android settings (開啟 Android 設定), or Choose keyboard (選擇鍵盤) button. Special Android settings +also include app-written **Manual steps** (手動操作步驟) in English or Traditional Chinese. Follow +those steps on the Android screen, then return to Particeps; the app checks the real setting again +automatically. Those instructions and buttons are fixed by the app. Neither a study file nor a +collector can insert arbitrary instructions or open a different screen. + +Some cards depend on an earlier one. Precise location must be granted before the Background +location button appears, and the research keyboard must be enabled before the keyboard picker can +be opened. A waiting card says which item to complete first. When every required card has a check +mark, press Done (完成). The app checks required access again at Done, Start study, and Resume, so a +grant removed in Android settings cannot be bypassed by an older on-screen state. While a study is +running, the foreground service waits 25 seconds between complete access checks. A check of the exact +Location request can take up to another five seconds, so a change made just after a completed check +normally has a 30-second code-path budget. Android can delay background execution, so this is not a +strict wall-clock guarantee. When a required loss is detected, Particeps closes study-wide event +admission, records the closed reason—without a study or participant identity—in a private safety +marker, and pauses collection. If saving the pause or stopping a source does not finish, Android +background work carries the same reason and keeps retrying even after the foreground service stops; +recovery checks both records before any source can restart. They are removed only after the pause and +cleanup succeed. The relevant Access cards replace the collection controls, and the header +still says Paused rather than sending you back through setup. Losing an optional-only capability +closes the separate event gate for only its sources before pausing them; the other sources keep +working, and an affected gate reopens only after its source successfully starts again. ### Notifications (通知) -Used for three things: scheduled study activities, the notification that stays visible while collection is running, and a once-a-day reminder of where the study stands. Activities use Android's background work system, which is not an exact alarm: battery saving, Doze, or system scheduling can delay them. +Notifications are required in every study, including one with no scheduled study activities. The +card's Used by line reads **Study status and ongoing collection; scheduled activities when +configured** (研究狀態與持續收集通知;設定排程活動時也用於活動通知). Press Allow (允許) for Android's notification +permission dialog. Particeps also verifies that Android allows notifications for the app and that +the ongoing-collection and daily-status channels are enabled. When the study actually contains +scheduled activities, it checks that channel too. If a required channel is off, the card opens the +app's notification settings and names the channels to restore. -The daily reminder says either that the study is still collecting, or that it is paused and since when. It exists for the second case: a pause changes nothing else on the phone, so a study you meant to resume can sit stopped for weeks without anything saying so. It is a quiet notification, with no sound. It names only the app and the state, never the study, so it discloses nothing to someone glancing at your lock screen. It stops when the study finishes or you withdraw. +Notifications are used for scheduled study activities when the study has them, the notification +shown while collection is running, and a once-a-day reminder of where the study stands. Activities +use Android's background work system, which is not an exact alarm: battery saving, Doze, or system +scheduling can delay them. + +The daily reminder says either that the study is still collecting, or that it is paused and since when. It exists for the second case: a pause changes nothing else on the phone, so a study you meant to resume can sit stopped for weeks without anything saying so. It is a quiet notification, with no sound. It names only Particeps and the collection state, never the study; someone glancing at your lock screen can still learn that this phone uses Particeps and whether collection is running or paused. It stops when the study finishes or you withdraw. Turning off a required notification channel makes Particeps pause the study at its next access check rather than continue without that status surface. ### Sensor hardware (感測器硬體) and basic network state Accelerometer, gyroscope, ambient-light, and proximity items are hardware checks, not permission -dialogs, so their rows do nothing when tapped. A required source blocks enrollment when its sensor -is absent; an optional source remains off. Connection type uses only Android's ordinary -network-state permission and has no row of its own. +dialogs, so their cards have no action button. A required source blocks enrollment when its sensor +is absent; an optional source remains off. The card explains that there is no setting or substitute +that can enable missing hardware. Connection type uses only Android's ordinary +network-state permission and has no Access card of its own. ### Usage access (使用情況存取權) -Data volume and app and screen use need "Usage access". That is a special Android setting, and it lets an app see which apps have been in the foreground and how much data the device has used. You grant it on an Android system screen, not inside this app, and Android controls that screen. +Data volume and app and screen use need "Usage access". If both are enabled, the single card names +both under Used by. That is a special Android setting, and it lets an app see which apps have been +in the foreground and how much data the device has used. Press Open Android settings +(開啟 Android 設定), find and +open Particeps, turn on Permit usage access, and return to Particeps. Android controls that screen. You can turn it back off later. The affected source then stops receiving data and reports a problem. It does not invent replacement values for the gap. -### Precise location (精確位置) and Background location (背景位置) - -Android may ask about precise location and background location separately. These are requested only if the study actually lists a location source. Continuous background collection is accompanied by a visible foreground-service notification, the kind Android requires an app to show while it does ongoing work. That is how you can see when collection is running. You can decline optional location, or stop it later with pause or withdrawal. - -### Enable the research keyboard (啟用研究鍵盤) and Select the research keyboard (選用研究鍵盤) - -The research keyboard is an Android input method (a keyboard app, the same kind of component as any third-party keyboard you might install). Turning it on takes two deliberate actions from you: enable it in Android's system settings, then select it in the keyboard picker. +### Precise location (精確位置), Android location services (Android 定位服務), and Background location (背景位置) + +Android handles precise location and background location separately. These cards appear only if the +study lists a Location source. First press Allow (允許) on Precise location and grant +precise rather than approximate location. Until that is done, Background location says to complete +Precise location first and has no action button. + +After precise location, the Android location services card checks more than the phone-wide location +toggle. It asks Android whether the phone can satisfy the exact accuracy, interval, batching, and +minimum-distance request in the signed study. If Android says a setting must change, press Open +Android settings (開啟 Android 設定), turn on Use location and the required location-accuracy +services, then return. If Android reports that the request cannot be satisfied or cannot be checked, +the card blocks a required Location source and tells you to contact the research team; Particeps +does not guess that a degraded provider is equivalent. + +Once precise location and the configured location request are ready, press Open Android settings +(開啟 Android 設定) on Background location. The app opens Android's **App info** for Particeps; it +does not try to request background location through a second permission pop-up. Open Permissions, +then Location, choose the background option Android displays on that phone (often **Allow all the +time** in English), and return to Particeps so it can check the setting. Continuous background +collection is accompanied by a visible +foreground-service notification, the kind Android requires an app to show while it does ongoing +work. If optional Location access is restored during a run, Android first confirms that the service +has added its Location type and only then does the Location source restart. If that access is removed, +Particeps first closes Location event admission and pauses the source, then removes the Location type +from the service. You can decline optional location, or stop it later with pause or withdrawal. + +### Enable the research keyboard (啟用研究鍵盤) and Select the research keyboard (選擇研究鍵盤) + +The research keyboard is an Android input method (a keyboard app, the same kind of component as any +third-party keyboard you might install). Turning it on takes two deliberate actions from you. First, +press Open Android settings (開啟 Android 設定), open Manage on-screen keyboards, enable Research keyboard, confirm +Android's warning, and return. Only after Particeps confirms that step does Choose keyboard +(選擇鍵盤) appear for the second card; press it and choose Research keyboard in Android's picker. Only touches on this keyboard's own surface are visible to it. The app does not use an Accessibility Service — the Android feature that can observe activity across all apps — to watch touches anywhere else. Read section 5 before you enable it. ## 5. Important warning about the research keyboard -Research events from the keyboard do not include the actual key identity, the text you submitted, the surrounding text, the clipboard, or suggestions. Some fields are excluded: a password field, or a field an app marks as private or as no-personalized-learning. Touch collection is switched off for that field, and the keyboard shows "Touch capture disabled for this field". +Research events from the keyboard do not include the actual key identity, the text you submitted, the surrounding text, the clipboard, or suggestions. Some fields are excluded: a password field, or a field an app marks as private or as no-personalized-learning. Touch collection is switched off for that field, and the keyboard shows "Touch capture disabled for this field" (「此欄位已停用觸控資料收集」). **"No text" does not mean "no risk."** The key category, the relative position of your touch, and the timing can still reveal patterns in what you type. A skilled analyst working with such data can infer more than a list of field names suggests. Third-party apps also sometimes fail to mark sensitive fields correctly, and when that happens, the field is not protected by the rule above — the app has no way to detect the mistake. @@ -271,17 +366,33 @@ The research keyboard is a basic English-letter QWERTY layout. It is not a passw ## 6. Starting a study -On the last setup step the fifth dot is the current one and the panel holds two things: Start study (開始研究) and Withdraw (退出研究). Nothing is collected in this state, and the app prints no state name for it — during setup the header shows your position, not a status. Collection begins only when you press Start study (開始研究). +On the last setup step the fifth dot is the current one and the panel holds two things: Start study (開始研究) and Withdraw (退出研究). Nothing is collected in this state, and the app prints no state name for it — during setup the header shows your position, not a status. When you press Start study (開始研究), Particeps first asks Android to start the visible foreground service and waits up to five seconds for Android to confirm its notification and service types. No source starts before that confirmation; if it fails or times out, the study remains not running. Resume uses the same ordering. -The moment you do, the five dots are replaced, permanently, by a status line, and the panel below becomes the collecting screen. +After that confirmation succeeds and the study starts, the five dots are replaced, permanently, by a status line, and the panel below becomes the collecting screen. While a study is collecting: - Continuous sources run under a visible research foreground service, so the ongoing "Research collection active" notification is present the whole time. +- If Android cannot keep an acknowledged foreground service while changing its service type, the + app closes all event admission and pauses rather than collecting without that visible host. A + failed Location upgrade may leave unrelated sources running only when Android confirms a safe + non-location foreground-service fallback. - Signed interventions are scheduled as one-time, repeating-interval, or daily local-time activities. A schedule may count calendar time or only time spent actively collecting. -- When the study's duration is reached, the system completes a collecting or paused study for you. Battery-saving scheduling can delay this. - -After you restart your phone, only a study that was Collecting (收集中) tries to resume. If you force stop the app, Android may block its work until you open it again. Open the app and check the status line if you are unsure. +- When the study's duration is reached, the system completes a collecting or paused study for you. + Battery-saving scheduling can delay when that completed status appears, but every observation is + checked against the exact same-boot Start deadline, so the delay cannot extend the data window. + +After you restart your phone, Particeps cannot safely use the adjustable wall clock to infer how much +of an active study elapsed while the old boot was unavailable. It therefore keeps every source closed +and moves a previously Collecting (收集中) study to a typed scheduling-failure pause; a study already +Paused (已暫停) stays paused and cannot Resume across that boot boundary. It does not use wall time as +a fallback or silently begin a new duration. Separately, Android can redeliver an earlier service +request while Particeps rebuilds its process within the same boot. A short-lived neutral restoration +notification with no study title may then appear. The app removes it while stopping the stale service +unless the saved state, clock boundary, and current access all revalidate; only an acknowledged active +notification with the exact service types can precede source activation. If you force stop the app, +Android may block its work until you open it again. Open the app and check the status line if you are +unsure. ### Scheduled activities and surveys @@ -378,7 +489,7 @@ Finishing or withdrawing does not strand data the research team was already owed ### Pause and resume -When you press Pause (暫停), the app writes a pause boundary, stops the sources, and flushes events that were already queued before that boundary. Once the status line shows Paused (已暫停), no new study events are accepted for the period you are paused. The line under it tells you when the pause started and how long it has lasted. Data already collected stays on your phone, encrypted. The ongoing notification and any visible scheduled-activity notification go away, and the daily reminder starts saying you are paused instead. A survey cannot be opened or submitted while paused. +When you press Pause (暫停), the app takes a pause boundary, stops the sources, closes their event gates, waits for events already admitted before that boundary, and then saves the paused state. If a source cannot stop cleanly, the app records a safety pause and keeps retrying cleanup instead of reporting an ordinary participant pause. Once the status line shows Paused (已暫停), no new study events are accepted for the period you are paused. The line under it tells you when the pause started and how long it has lasted. Data already collected stays on your phone, encrypted. The ongoing notification and any visible scheduled-activity notification go away, and the daily reminder starts saying you are paused instead. A survey cannot be opened or submitted while paused. Pressing Resume (繼續收集) starts a new collection interval. Data volume and app and screen use are not backfilled: the app does not go back and collect what happened while you were paused. Calendar time and scheduled-activity availability still pass during a pause, so the app expires missed activities and reconciles any remaining ones when you resume. @@ -446,8 +557,8 @@ Uninstalling the app or clearing its app data also destroys the local keys and d | `STUDY_IMPORT_FAILED` | The app's own record that an import did not finish, whether it came from a link or from a file. Nothing was imported and nothing already on the phone changed | | Check this against the fingerprint your research team published (請與研究團隊公佈的金鑰指紋核對) | Ordinary for most studies; compare the fingerprint on the Configuration signature (設定檔簽章) block with the one your research team published, and if you do not have one, ask before consenting | | The fingerprint does not match the one you were given | Do not consent. Contact your research team through details you already had, not details taken from the study screen | -| A required item in the access list is not granted | Tap that row, finish on the Android screen it opens, and return to the app; if you do not want to grant it, do not start | -| A source shows `ACCESS_UNAVAILABLE` | The access it needs is not granted. The other sources keep working; grant it only if you want to | +| A required Access card is not granted | Use the named button on that card and follow its numbered Manual steps, if shown. If it says to complete another item first, finish that prerequisite first. Return to Particeps so it can re-check Android. If you do not want to grant it, do not start | +| An optional source shows `ACCESS_UNAVAILABLE` | The access it needs is not granted. Its source is paused and the other sources keep working. Particeps tries to restart it when Android reports the capability available; its event gate opens only if that restart succeeds, otherwise it stays off with a failure code. | | Motion sensor unavailable | The device has no compatible accelerometer; the app does not fabricate substitute data, so a study that requires it cannot start | | Gyroscope, ambient-light, or proximity sensor unavailable | The phone lacks that hardware; required collection cannot start and optional collection stays off. There is no substitute or inferred fallback. | | A source shows a red dot and any other code | Pause first, check that permission or special access, then try to resume; if it still fails, contact the research team and quote the code | @@ -487,11 +598,11 @@ If you need to describe your situation to your research team, the internal names ## 12. If you tested an earlier version of this app -This app used to be called Android Data Collector. As far as your phone is concerned, an earlier install and this one are two completely separate apps, and that is also true of an earlier install already carrying the name Particeps. The older install cannot update into this one. The two sit side by side, and installing Particeps brings nothing across: no study, no consent, no collected events, and no export record moves from one to the other. The older app keeps running on your phone, with everything it already holds, until you remove it. What it can no longer do is deliver: it writes files in the older format, and if its study sends data automatically, the research team's server no longer accepts what it sends. [CHANGELOG.md](../CHANGELOG.md) records which release changed what, and why no release can update an earlier install in place. +If your phone has `v1.0.0-rc.5`, install the signed rc.6 APK directly over it. Do **not** uninstall rc.5 first. Rc.5 established the current application ID and production signing certificate, so Android accepts rc.6 as an in-place update and preserves the study, consent, encrypted events, Keystore key, notification permission, and keyboard settings already owned by that app. -**Ask your research team before you uninstall the older app.** Uninstalling it destroys the key it keeps on your phone, and after that the encrypted data it collected cannot be read by anyone — not by you, not by them. There is no recovery and no backup. If they want what the older app collected, export it from that app while it is still installed and send them the file. Tell them which version it came from, because an export written by an Android Data Collector install needs the older tools to open it. Only then uninstall. +Rc.4 and earlier are different. They use another application ID or signing certificate and cannot update directly to rc.6. They may sit beside the current Particeps app and bring no on-device study, consent, events, or export history across. **Ask your research team before uninstalling one of those older apps.** Uninstalling destroys its on-device key, after which anything not exported can no longer be read. Export first and tell the team which version produced the file. An rc.4 `.partexp` export already uses the current format and current tooling can read it; an rc.3-or-earlier pre-rename export requires matching pre-rename analysis tooling and is intentionally rejected by current tools. No current tool migrates either app's encrypted on-device state. [CHANGELOG.md](../CHANGELOG.md) records the exact release boundary. -If your study uses the research keyboard, you have to enable it and select it again for Particeps. Android treats it as a different keyboard, so the setting you made for the older app does not carry over. Section 4 describes the two steps. +When moving from rc.4 or earlier, enable and select the research keyboard again because Android treats the current package as a different keyboard. An rc.5→rc.6 in-place update keeps that Android setting. Section 4 describes the two setup steps if they are needed. --- diff --git a/docs/researcher-guide.md b/docs/researcher-guide.md index 8366192..04f3bca 100644 --- a/docs/researcher-guide.md +++ b/docs/researcher-guide.md @@ -239,7 +239,9 @@ Constraints enforced by daily triggers use `HH:mm` in the device's current time zone. Offsets must fall inside the study, availability is 1–525,600 minutes, and the signed study is capped at 512 total occurrences so durable idempotency metadata remains inside its encrypted 1 MiB bound. - WorkManager timing is best effort, not an exact alarm. Any intervention requires notification access. + WorkManager timing is best effort, not an exact alarm. Every study requires notification access + during setup, whether or not it declares an intervention; interventions use that already-required + capability when present. - `storage.maximum_local_bytes` is 8 MiB–8 GiB (8,388,608–8,589,934,592). - `signer` carries exactly `key_id` and `public_key`. `public_key` is the raw 32-byte Ed25519 public half encoded as unpadded base64url. `key_id` must equal the @@ -336,7 +338,7 @@ Per-collector configuration: | `network_state.v1` | `include_bandwidth_estimates` boolean | | `network_usage.v1` | `transports` non-empty subset of `wifi`/`mobile`; `poll_interval_minutes` 1–1,440 | | `usage_events.v1` | `poll_interval_minutes` 1–1,440 | -| `location.v1` | `interval_millis` 1,000–3,600,000; `minimum_interval_millis` 500 to `interval_millis`; `maximum_batch_delay_millis` 0–86,400,000; `minimum_displacement_millimeters` 0–10,000,000; `priority` `BALANCED` or `HIGH_ACCURACY`. This collector always requires precise location; `priority` trades power against accuracy within it, and there is no coarse-only mode. | +| `location.v1` | `interval_millis` 1,000–3,600,000; `minimum_interval_millis` 500 to `interval_millis`; `maximum_batch_delay_millis` 0–86,400,000; `minimum_displacement_millimeters` 0–10,000,000; `priority` `BALANCED` or `HIGH_ACCURACY`. This collector declares precise location, request-specific Android location-service readiness, and background location; its `required` flag decides whether they block setup. `priority` trades power against accuracy within precise location, and there is no coarse-only mode. | | `keyboard_touch.v1` | `trajectory_sampling_hz` 1–120 | Both polling collectors, and scheduled delivery, accept a one-minute floor. That floor exists @@ -346,10 +348,17 @@ neither does. Treat a minute as a diagnostic setting rather than a study setting battery, and for `network_usage.v1` it does not buy resolution: Android's own accounting is coarse and lags, so a one-minute poll gives you finer windows without giving you finer truth. -`required: true` means the study cannot start until that access is granted. An optional -collector still appears in the data step described below, marked *Optional*, and on the -participant dashboard. When its access is missing it is shown as blocked. The app does not +`required: true` makes that collector a required owner of every capability declared by its +descriptor. The app deduplicates capabilities shared by collectors, preserves every owner, and +makes the resulting access card required when at least one owner is required. Thus a required +`network_usage.v1` and optional `usage_events.v1` produce one required Usage access card that names +both collectors and marks only the latter owner Optional. An optional collector still appears in +the data step and participant dashboard. When its unshared access is missing it is shown as +blocked. Optionality is not an independent collector toggle: if its capability is already granted, +including because a required collector shares it, the optional collector can run. The app does not substitute, interpolate, or synthesise data for a blocked collector. +Notification access is a separate, unconditional required study capability; collector optionality +cannot make it optional. ### What the app tells participants each collector does @@ -370,6 +379,14 @@ Collectors without configuration fields read the same in every study. The exact `values-zh-rTW` counterpart; read it before you write your consent summary, because your participants will. +The Access step follows the same integrity boundary. It renders one card per deduplicated Android +capability, lists every collector or study feature that owns it, and marks optional owners. The app +alone supplies the English and Traditional Chinese labels, numbered manual steps, prerequisites, +and the explicit Allow, Open Android settings, or Choose keyboard action. Neither a signed +configuration nor a collector plugin can supply arbitrary setup text, a permission name, an +`Intent`, or a callback. Your consent and support materials may explain why the study needs an +item, but they must not claim to replace or alter the app's acquisition path. + **This is a floor, not a substitute for your consent summary** — the same argument made for the upload disclosure under *Scheduled upload* below. What the data step gives a participant is which sources are on, at what rate, and what each one cannot see. What it cannot give them @@ -615,15 +632,20 @@ already been distributed. ## 6. Build and distribute -Build and check the app with the command block in -[`CONTRIBUTING.md`](../CONTRIBUTING.md), which is the same one CI runs. +Build and check the app with both the host-side and attached-device command blocks in +[`CONTRIBUTING.md`](../CONTRIBUTING.md). CI runs the connected debug instrumentation suite on an API +34 emulator as well as the host-side tests, lint, and builds. Debug APKs are for internal testing only. For real deployment use the tag-triggered GitHub Actions release workflow; the required secrets and setup are described in the repository -[`README.md`](../README.md). The workflow publishes only APKs that have passed -`apksigner verify`, and the keystore is never committed. Sideloading uses the signed APK; -Google Play distribution uses the corresponding AAB and track process. Building the app is -not part of issuing a study: the same build verifies any correctly signed `.partcfg`. +[`docs/maintainers/release.md`](maintainers/release.md). On a release tag, the API 34 `connectedDebugAndroidTest` gate must pass +before the dependent job builds and signs the release APK. The workflow publishes only an APK that +has then passed `apksigner verify`, has exactly one signer, and matches the rc.5 production certificate +in the repository's [auditable identity anchor](../.github/android-release-signing-certificate.sha256). +It publishes that APK together with its SHA-256 checksum, and the keystore is never committed. +Particeps is distributed directly as this signed APK. This release process has no +Google Play listing or track prerequisite and does not publish or require an AAB. Building the app +is not part of issuing a study: the same build verifies any correctly signed `.partcfg`. Participants can import the `.partcfg` through the system file picker, or open an immutable `particeps://join/v1` link / QR generated by the Web authoring surface. Join hosting is transport @@ -730,14 +752,60 @@ OEM hardware: purpose, contact, consent summary — reads correctly beside translated app text, and that a participant whose phone is set to the other language still receives a configuration written in theirs. -- Consent; every required and optional access; behaviour after a denial; revoking access - mid-study. -- Start, pause, resume, finish, and withdraw for every configured collector. -- The daily status reminder: that it arrives, that it says the study is paused after a pause and - collecting again after a resume, and that finishing or withdrawing stops it. Reminders are a - day apart, so a pilot that runs for an afternoon will not show you one. If your study schedules - no interventions, grant notification access yourself first — the access step does not ask for - it, and without it nothing is posted. +- Consent; every required and optional access card; every Used by owner; behaviour after a denial; + and revoking access mid-study. Confirm required access is re-inspected at Done, Start study, and + Resume rather than relying on stale setup state. +- Start and Resume for every configured collector, including an instrumented foreground-service + failure: the host acknowledgement has a five-second timeout, the state stays `READY` or `PAUSED` + when it fails, and no collector starts before Android has accepted the notification and exact + service types. Then exercise pause, finish, and withdraw. +- During a run, turn off each required capability without returning to the Activity. The service + waits 25 seconds between reconciliations; the exact Location probe may use up to five more seconds, + giving a nominal 30-second code-path budget. Do not treat that as a wall-clock SLA because Android + can delay process execution. Confirm the study and collector gates close, an identity-free typed + marker records `REQUIRED_ACCESS_MISSING`, the matching `RUNNING → PAUSED` transition is persisted, + and remediation cards appear. Inject marker, metadata, and collector-teardown failures and verify + that reason-bearing WorkManager work retries after the foreground service stops. Kill the process + with only that work record remaining and confirm recovery pauses before starting any host or source; + conflicting or corrupt reasons must keep recovery closed. +- Inject an atomic event-append failure while sources are producing events. Confirm every admission + gate closes before the typed `STORAGE_FAILURE` request is exposed. Then fail marker and metadata + persistence together and confirm WorkManager's acknowledged retry is sufficient, by itself, to + keep a fresh process from starting the foreground host or any collector. Finally, delay and fail + retry cancellation: Resume must wait for acknowledged retirement, and a failed cancellation must + leave the safety pause pending with an autonomous retry witness. +- Cancel Pause, Finish, duration completion, and Withdraw while a source is releasing callbacks. + Confirm `COLLECTION_TEARDOWN_FAILURE` is durable before cancellation returns, a fresh process enters + `PAUSED` before any host or source starts, and a teardown failure attempted from an already paused + study does not rewrite the earlier participant-pause reason or timestamp. +- Repeat for optional-only access while both affected and unaffected sources are producing events. + Confirm only the affected per-collector gate closes before source pause, no later event from that + collector is accepted, unrelated collectors continue, and a fresh gate opens only after successful + restoration. For optional Location specifically, confirm the service acknowledges the `location` + type before the collector resumes, and confirm revocation gates and pauses the collector before the + service drops that type. +- Inject an in-run Location host promotion failure. A confirmed non-location fallback may keep only + unrelated collectors running; promotion plus fallback failure must close every gate and persist + `COLLECTION_HOST_FAILURE`. A demotion failure must reach the same typed safety pause. +- Inject failures and caller cancellation into deadline, daily-status, upload, intervention, and + safety-retry WorkManager mutations. The operation must be acknowledged before Start, Resume, or + recovery succeeds; otherwise `WORK_SCHEDULING_FAILURE` remains durable and a fresh process must + not reopen the host or any source. Repeat after a terminal transition and confirm stale deadline, + reminder, and intervention work is retired while undelivered terminal upload work remains. +- Change wall time within one boot and leave an intentionally stale deadline WorkSpec. Confirm the + one `PARTICIPANT_STARTED` transition remains the duration origin, monotonic elapsed time is used, + and same-boot recovery replaces the stale deadline rather than granting a new duration. Then + reboot with both forward and backward wall-clock values: either cross-boot case must establish + `WORK_SCHEDULING_FAILURE` and keep every host and collector closed, because wall time is never a + lifetime fallback. +- At the duration boundary, inject collector and occurrence observations immediately before and + exactly at the monotonic deadline. The former must persist and the latter must be rejected. Wake + the deadline worker early and late: an early wake retries without completing, while a late wake + may delay the visible terminal state but must not admit any post-deadline observation. +- The unconditional Notifications card, including a study with no interventions; denial must block + setup/start/resume. Then verify that the daily status reminder arrives, says the study is paused + after a pause and collecting again after a resume, and stops after finishing or withdrawing. + Reminders are a day apart, so a pilot that runs for an afternoon will not show you one. - Two exports and two successful decryptions from each of `RUNNING`, `PAUSED`, `COMPLETED`, and `WITHDRAWN`. - If the study uploads: the consent step's upload block against your consent document, a @@ -749,13 +817,22 @@ OEM hardware: - Fail-closed behaviour with the wrong private key, the wrong configuration, and truncated or modified ciphertext. - Reboot, force stop, low storage, wall-clock changes, Doze, long uptime, and the OEM's - foreground-service restrictions. + foreground-service restrictions. For a redelivered service intent, confirm the notification first + presents a short-lived neutral restoration state with no study title, then either revalidates + durable `RUNNING` plus current access and replaces it through a fresh exact-type acknowledgement + before collectors activate, or removes it while stopping the stale service. - Real accuracy, batching, and battery cost of your location and accelerometer parameters - on the target hardware. + on the target hardware. Verify the order Precise location → Android location services → + Background location. The middle card must check the exact signed Fused Location request rather + than only the global location toggle. Then verify that Background location opens Particeps App + info for the participant to choose the background option Android localizes on that device, + rather than issuing a second runtime permission request. - Usage Events and NetworkStats latency, gaps, multi-window, VPN, Wi-Fi/mobile handover, and zero-traffic windows. -- Research keyboard: the sensitive-field cut-off, editors in different apps, switching - keyboards, and the risk of a participant leaving it enabled by mistake. +- Research keyboard: Enable must precede Select; then test the sensitive-field cut-off, editors in + different apps, switching keyboards, and the risk of a participant leaving it enabled by mistake. +- Shared Usage access: one card and one system grant for `network_usage.v1` plus + `usage_events.v1`, with both owners and their individual required/optional state visible. - Peak event rate against the quota you chose, export time and memory, and whether the participant screens are comprehensible to someone outside your team. @@ -768,10 +845,14 @@ a row of dots showing how far along they are: 2. **Data** — every enabled collector, described by the app from your parameters. 3. **Consent** — your `consent.summary`, then the signature and upload blocks the app asserts itself, then the agreement checkbox. -4. **Access** — the Android access the configured collectors need, one row each. Tapping an - outstanding row opens the screen that grants it, except the motion-sensor check, which is - hardware and nothing to grant. Optional ones are labelled, and only the required ones - block the next step. +4. **Access** — one card per deduplicated Android capability, with a Used by list naming every + collector or study feature that owns it. Notifications are required in every study. Missing + items provide an explicit Allow, Open Android settings, or Choose keyboard button; special + settings also show app-authored numbered instructions. Hardware checks have no action, and + prerequisite cards wait for Precise location and configured location-service readiness before + Background location, and Enable keyboard + before Select keyboard. A card is required when any owner is required, and only cards whose + owners are all optional may be skipped. 5. **Start** — importing collects nothing; this press is what starts collection. Re-entering the consent state returns to the data step, so nobody reaches the checkbox @@ -793,13 +874,21 @@ own, and no configuration field switches it off, rewords it, or adds to it. The arrives about a day after the start press. Starting or stopping collection retracts a reminder already on screen rather than posting a replacement, so a paused study is never left asserting that it is still collecting. Finishing, completing on the duration deadline, and withdrawing cancel the -schedule and clear the standing notification. It needs notification access, which the access step -requires only when your study schedules interventions. In a study without them it therefore reaches -only the participants who granted notifications for some other reason. None of that makes the +schedule and clear the standing notification. Notification access is an unconditional required +setup item for every study, not a consequence of configuring interventions. None of that makes the reminder a guarantee that a participant has been reminded. Its timing is best effort rather than an exact alarm, and a force stop blocks it until the app is opened again. A participant can also turn -its channel off in Android's notification settings or revoke notification access, either of which -stops the reminder without stopping the study. +its channel off in Android's notification settings, which stops the reminder without changing the +permission. The app verifies the collection and daily-status channels for every study and the +intervention channel only when the signed configuration contains interventions. Revoking the +permission or disabling a required channel during a run is detected by the foreground service; +the next reconciliation begins after the service's 25-second wait (the five-second probe extension is +specific to Location), closes admission, and pauses collection. The private typed safety marker and +matching WorkManager retry keep that fail-closed transition moving even though the foreground service +is then stopped; process recovery, Start, Resume, and running reconciliation read both before opening +an event gate. Resume remains blocked until notification state is restored, stale retry work has +retired with an acknowledged WorkManager cancellation, and the service has acknowledged startup +again. Researchers must not: diff --git a/docs/system-design.md b/docs/system-design.md index cd75698..7d5ee9d 100644 --- a/docs/system-design.md +++ b/docs/system-design.md @@ -79,7 +79,7 @@ flowchart LR | `:core:protocol` | Signed envelope, immutable join URI, signature verification, optional signer pinning, validity-window and version checks | | `:core:collector-api` | Collector lifecycle, health, registry, access contract, and the shared callback dispatcher | | `:core:crypto` | Protocol v1 raw-key Ed25519 verification and fixed-suite RFC 9180 HPKE over raw X25519 keys; Tink remains internal only, never a wire keyset | -| `:core:access` | Runtime permission, Usage Access, input-method, and hardware preflight | +| `:core:access` | Closed Android access rules: semantic order, prerequisites, runtime-permission/settings/picker actions, Usage Access, input-method state, and hardware preflight | | `:core:experiment-runtime` | Command serialization, state machine, collector supervision, event admission gate, durable occurrence lifecycle, and atomic survey submission | | `:core:study-application` | The single active-study session; recovery and coordination of storage/access/host/work/export/upload ports, schedule reconciliation, and the upload watermark | | `:core:storage` | Android Keystore, encrypted metadata, appended event segments, reclaiming delivered ones, and strict one-event journal recovery | @@ -96,6 +96,67 @@ permissions. `CollectorRegistry` rejects an ID that is not compiled in, and rejects duplicate IDs at construction. +### Access planning and acquisition + +Access declaration, study policy, Android acquisition, and presentation are deliberately separate. +Each `CollectorDescriptor` carries a closed `Set` alongside its event contract. A +collector plugin has no `accessRequirements` callback and cannot provide a permission string, +`Intent`, UI text, or action. For each configured collector, `CollectorRegistry` combines those +descriptor capabilities with the configuration's `required` flag and emits +`CollectorAccessRequirement` values that retain the descriptor ID as owner. + +`StudyAccessPolicy` adds one unconditional required `NOTIFICATIONS` owner for the study's status, +ongoing collection, and scheduled activities, then groups all requirements by `AccessKind`. The +group keeps every `StudyAccessOwner`; its merged requirement is required when any owner is required. +Shared Usage Access for `network_usage.v1` and `usage_events.v1` is therefore acquired once without +losing which collector requested it or either collector's optionality. + +`:core:access` owns an exhaustive `AccessRules` entry for every `AccessKind`. A rule fixes semantic +order, prerequisites, an optional closed `SetupAction`, and optional app-authored guidance. Fine +location precedes request-specific Android location-service readiness, which precedes background +location; background location remains blocked until both earlier capabilities are satisfied and +then uses the system App details screen. Enabling the research keyboard precedes +selecting it; the first uses input-method settings and the second the system input-method picker. +Notifications and foreground location are runtime-permission actions, Usage Access uses its system +settings screen, and hardware capabilities have no action. Settings intents are resolved to a +system component; a missing handler becomes explicit `SYSTEM_HANDLER_MISSING`, never a different +fallback action. + +`AccessInspectionRequest` carries the plan plus two app-derived, closed contexts: the exact five +fields of a configured Location request and notification purposes rather than raw channel IDs. +`AccessManager.inspect` is suspendable so `GooglePlayLocationSettingsProbe` can call +`SettingsClient.checkLocationSettings` using the same accuracy, intervals, batching, and distance as +the collector. It combines that result with the global location toggle and resolves each rule to +`Satisfied`, `ActionRequired`, `BlockedByPrerequisites`, or an explicit `Unavailable`; neither an +unavailable provider nor a failed check is treated as degraded success. Notification inspection +always checks the collection and daily-status channels and checks the intervention channel only +when the configuration has interventions. + +`StudySessionManager` re-inspects the whole plan before completing access setup, before Start, and +before Resume. The running `CollectionService` waits 25 seconds between reconciliation attempts, +independent of Activity lifecycle. A location-settings probe has its own five-second deadline, giving +that code path a nominal 30-second budget after the preceding completed check. This is not a strict +wall-clock SLA: Android can delay process and coroutine execution. +At setup, first Start, or Resume, an unsatisfied required capability rejects that command and leaves +`ACCESS_SETUP`, `READY`, or `PAUSED` unchanged. During a `RUNNING` reconciliation, the same finding +begins a typed fail-closed safety pause: the application closes every event gate, records +`REQUIRED_ACCESS_MISSING` in an identity-free marker in app-private no-backup storage, and persists +`PAUSED`. Foreground-host loss uses `COLLECTION_HOST_FAILURE`; a +failed or cancelled source release uses `COLLECTION_TEARDOWN_FAILURE`; and an untrustworthy mutable +store operation uses `STORAGE_FAILURE`. Failure to establish or retire background work uses +`WORK_SCHEDULING_FAILURE`. Runtime storage and teardown failures invoke the app-owned +`SafetyPauseWitness` before the failing operation returns: it must either persist the marker or await +WorkManager's database acknowledgement for unique work carrying the same reason. Process recovery, +Start, Resume, and running reconciliation merge the marker with active retry work before starting +any host or collector. Conflicting or unreadable reasons keep recovery closed. After `PAUSED` and +cleanup are durable, one non-cancellable completion section clears the marker, awaits retry +cancellation, and only then clears in-memory pending state, so a stale worker cannot race a resumed +study. A cleanup failure that happens after an earlier participant pause does not rewrite that +historical transition; its private typed witness records the later cleanup obligation. +Optional-only loss closes only the affected collectors' admission gates before pausing their sources +and marks them `BLOCKED_ACCESS`; each gate reopens only after that source starts or resumes +successfully. + ### The participant interface `CollectorDashboard.kt` renders the whole participant surface from `StudyUiState`. Setup is a fixed @@ -119,6 +180,15 @@ about what the source cannot see. The per-collector statement of what a source c the table in [`researcher-guide.md`](researcher-guide.md), which is documentation for the researcher designing the study and not something the app renders. +The access step renders one `AccessCard` per deduplicated capability. Every card lists the +collector or study-feature owners and marks optional owners; a card is optional only when every +owner is optional. Missing special access shows numbered guidance, and actionable states expose one +explicit button for the closed `SetupAction`. Prerequisite-blocked cards name the earlier item and +have no action; unavailable hardware or system settings show an explicit explanation. Labels, +owner descriptions, manual steps, and action labels are exhaustive English and Traditional Chinese +resources in `AccessPresentation.kt` and `res/values*`. Neither the signed configuration nor a +collector can inject setup text or an Android action. + Every participant-facing string is a resource; none is written into Kotlin. The app ships English (the default) and Traditional Chinese, declared in `res/xml/locales_config.xml` and referenced by the manifest's `android:localeConfig`. `AppLocale` reads the offered list from that manifest @@ -223,6 +293,24 @@ stateDiagram-v2 Every transition is persisted encrypted before the UI is updated. `ExperimentRuntime` serializes commands behind a mutex, and an illegal transition returns a fixed reason code. Study time is recorded three ways at once: UTC wall clock, `elapsedRealtimeNanos`, and a boot session ID. +The application layer re-inspects required Android access before the `ACCESS_SETUP → READY`, +`READY → RUNNING`, and `PAUSED → RUNNING` commands; a missing grant leaves the state unchanged. +After `RUNNING`, the foreground-service monitor can persist +`RUNNING → PAUSED / REQUIRED_ACCESS_MISSING` when required access is lost. If an in-run service-type +change leaves no acknowledged foreground host, the same safety boundary persists +`RUNNING → PAUSED / COLLECTION_HOST_FAILURE`. If a mutable store operation becomes untrustworthy, +the runtime closes every event gate and synchronously establishes a marker or acknowledged +WorkManager witness for `RUNNING → PAUSED / STORAGE_FAILURE` before that failing operation returns. +An unacknowledged deadline, reminder, upload, intervention, or retry mutation similarly persists +`RUNNING → PAUSED / WORK_SCHEDULING_FAILURE` rather than assuming WorkManager committed it. + +These names belong to separate closed taxonomies. A protocol `TransitionReason` explains one +authenticated lifecycle edge. `SafetyPauseReason` is exactly the five safety reasons above and is +also the payload of the private marker or retry; it becomes a transition reason only when the +durable source state is `RUNNING`. A failed access preflight during setup, first Start, or Resume +does not manufacture a `PAUSED` edge: the state remains `ACCESS_SETUP`, `READY`, or `PAUSED` and the +UI exposes a fixed incident code. Collector-health reason codes and upload-failure codes are +diagnostic fields, not lifecycle transition reasons. There is no `EXPORTED` state. Export availability is: @@ -239,24 +327,38 @@ the participant has already exported to external storage are outside the app's c ## 5. Runtime and the pause boundary -Each entry into `RUNNING` mints a new epoch token in the admission gate. A collector must hold the -token before it can emit an event, and each event carries its original observation time. On pause, -finish, or withdraw: +Each entry into `RUNNING` mints a new epoch token in the study-wide admission gate. Every collector +also has a separate gate, and its private `EventSink` returns a composite token from both gates. An +event is accepted only while both the study epoch and that collector's epoch remain valid, and each +event carries its original observation time. A participant pause or terminal command uses this +ordered boundary: 1. Capture a monotonic boundary and switch the gate to `DRAINING`. -2. Persist the state transition first. -3. Stop callbacks and polling, then flush already-queued events. -4. Accept only events from the same epoch whose observation time precedes the boundary. -5. Close the epoch. Older tokens are permanently invalid. - -This is why a pause cannot be polluted by new events arriving through a delayed callback queue. A -storage failure closes the gate immediately, records a fixed incident code, and attempts to fail -closed into `PAUSED`. +2. Ask every source to pause or stop, then close each collector epoch and the study epoch. +3. Await every write already admitted at the boundary. +4. During that drain, accept only events whose study and collector tokens are current and whose observation time + precedes the boundary. +5. Persist the participant pause or terminal transition only after source release and admitted writes + succeed. A failed or cancelled release persists `COLLECTION_TEARDOWN_FAILURE` instead; a terminal + transition is not claimed, and a participant pause is reported as a safety pause rather than a + clean participant boundary. + +This is why a pause cannot be polluted by new events arriving through a delayed callback queue. An +untrustworthy storage append closes the study gate and every collector gate while still holding the +metadata serialization lock and records a fixed incident code. Before the append returns failure, +the runtime calls the synchronous `SafetyPauseWitness`; marker failure falls back only to an awaited, +reason-bearing WorkManager enqueue. The session then completes durable `PAUSED` and cleanup. It +clears the runtime request only after either that transition and cleanup succeed or WorkManager has +confirmed the typed retry is durable; otherwise the closed request remains observable and is +retried. Required-access and host safety loss are stricter than a participant drain: they force-close +admission immediately, then wait for any already-executing store mutation before committing `PAUSED`. Each collector maintains its own health state: `STOPPED`, `ACTIVE`, `PAUSED`, `BLOCKED_ACCESS`, or -`FAILED`. A missing optional permission blocks only the collector that needs it. A missing required -permission prevents preflight from completing at all. One collector's failure does not stop the -others. +`FAILED`. A missing capability owned only by optional collectors closes each affected collector's +gate before source teardown while leaving the study gate and unrelated collector gates open. On +restoration, a fresh collector epoch opens only after source start or resume succeeds. A capability +shared with any required collector is required for preflight, while all owners remain visible in the +access plan. One collector's failure does not stop the others. ## 6. Implemented collectors @@ -272,9 +374,13 @@ others. | `network_state.v1` | Default network availability, transport, validated, metered, roaming, VPN, optional bandwidth estimates | `ACCESS_NETWORK_STATE` (a manifest normal permission) | | `network_usage.v1` | Device-total Wi‑Fi and mobile rx/tx bytes and packets, plus the interval the query covers | Usage Access | | `usage_events.v1` | App resumed/paused/stopped, screen, keyguard, and startup/shutdown raw events, including the foreground app's `package_name` when the platform reports one | Usage Access | -| `location.v1` | Fused Location fixes: latitude, longitude, source time, accuracy, speed, altitude, bearing, mock flag | Fine and background location, per the `required` flags in the configuration | +| `location.v1` | Fused Location fixes: latitude, longitude, source time, accuracy, speed, altitude, bearing, mock flag | Fine location, exact configured Android location-service readiness, and background location; all inherit this collector configuration's `required` flag | | `keyboard_touch.v1` | Touch position relative to the bounds of the pressed key, timing, pressure, size, orientation, tool type, key category | The study input method must be enabled and selected | +In addition to collector-owned capabilities, every study has one required Notifications capability. +It is a study-feature owner rather than a synthetic collector and remains required when the +configuration contains no interventions. + Network usage is the coarse device total from Android's `NetworkStatsManager.querySummaryForDevice` with `subscriberId=null`. It is not an instantaneous rate and not a per-app attribution. @@ -294,7 +400,8 @@ The manifest disables backup, and the cloud-backup and device-transfer rules exc The app neither requests StrongBox nor verifies hardware backing, so this is a Keystore isolation claim, not an absolute hardware-protection claim. -- Metadata: an `AtomicFile` in the format `PTCMET01 | random 96-bit IV | ciphertext+tag`. `PTCMET01` +- Metadata: a repo-owned acknowledged atomic document in the format + `PTCMET01 | random 96-bit IV | ciphertext+tag`. `PTCMET01` carries the fresh-per-import instance ID, optional researcher-assigned ID, upload watermark, retained floor, and durable intervention occurrence states. It also holds `last_events`, the most recent event per collector, which is why opening a study needs no scan of @@ -309,14 +416,32 @@ claim, not an absolute hardware-protection claim. checks against the index in the filename. - Each frame: `sequence(u64) | ciphertextLength(u32) | random IV(12) | ciphertext+tag`. - The AAD binds the event format, the opaque study locator, and the sequence number. -- Every event append is followed by an `fsync`; metadata is committed through `AtomicFile`. +- Every event append is followed by an `fsync`. Metadata, the transaction journal, the active-study + envelope or deletion tombstone, safety-pause markers, and new segment headers use + `AcknowledgedAtomicFile`. It writes and closes two independently `fsync`ed same-directory copies, + `.pending` and `.replacement`, then directory-syncs both. The checked atomic rename consumes only + `.replacement`; `.pending` remains as an uncertainty witness until exact base-file readback and a + second parent-directory sync acknowledge the replacement. Only then is witness deletion attempted + and directory-synced. A cleanup failure may leave the witness and conservatively block a later + read, but cannot retroactively turn an acknowledged base mutation into a reported failed commit. + Any unresolved witness, rc.5 framework-`AtomicFile` `.new`/`.bak` residue, or unknown event-directory + entry blocks recovery; the app never guesses whether to promote or parse incomplete bytes. Only a + later explicit write of caller-supplied known bytes may retire and directory-sync all stale + artifacts before beginning a new two-copy replacement. New segment creation is directory-synced + before append can succeed. Reclaim is different: its authoritative retained floor commits first; + physical unlink and its directory sync are best-effort cleanup that stops at the first failure and + is retried later, because every affected segment was already confirmed delivered. - An event plus its resulting metadata is one recoverable commit. Before appending, the store writes - an encrypted `PTCTXN01` journal containing the resulting metadata before the event append. - If the journal is one boundary ahead and its event is durable, recovery authenticates that exact - tail event and commits the journal metadata. If the event is absent, it discards the prepared - journal. A same-boundary leftover is discarded with main metadata authoritative. Any other - boundary, malformed journal, or event mismatch fails closed. This is the one write path used for - occurrence lifecycle events and survey submissions; there is no independent draft store. + an encrypted `PTCTXN01` journal containing the proposed successor plus a synthetic + `RUNNING -> PAUSED / STORAGE_FAILURE` boundary before touching an event byte. If the exact event is + durable, recovery authenticates that tail and keeps it in the PAUSED boundary; if the event is + absent or only a truncated final frame exists, recovery removes the partial tail and keeps the + prior event boundary PAUSED. The journal remains as provenance until the runtime resolves it with + the application-owned first winning safety reason; that lets a pre-existing access, host, work, + or teardown marker replace the synthetic storage reason even across repeated process deaths. + Only a later acknowledged main-metadata mutation can prove a same-boundary journal stale. Any + other boundary, malformed journal, or event mismatch fails closed. This is the one write path used + for occurrence lifecycle events and survey submissions; there is no independent draft store. - The active signed configuration is held separately, under its own Keystore key, as `PTCACT01 | random 96-bit IV | ciphertext+tag`. - The local quota comes from the configuration and is bounded to 8 MiB-8 GiB. Encoded metadata is @@ -385,11 +510,13 @@ has been reclaimed. `eventCount` stays the lifetime total, and `nextSequenceNumb persisted metadata rather than being recomputed from the scan, so a sequence number is never reissued after reclaiming. The readable window is `[retainedFromSequence, eventCount]`. -The floor is persisted before the segments below it are unlinked. A crash in between leaves more on -disk than the floor claims, which is harmless. The load path adopts the first sequence it actually -finds, and the next pass finishes the job. Finding *less* on disk than the floor claims is fatal on -load — `Event segments below the retained floor are missing` — because it is indistinguishable from -a prefix having been tampered away. +The floor is persisted and made authoritative in memory before the segments below it are unlinked; +a later metadata save is forbidden from moving it backwards. Physical cleanup stops at its first +unlink failure, so the files that remain are one contiguous suffix. A crash or partial cleanup can +therefore leave more on disk than the floor claims, which is harmless: the load path adopts the first +sequence it actually finds, and a later pass can finish the prefix cleanup. Finding *less* on disk +than the floor claims is fatal on load — `Event segments below the retained floor are missing` — +because it is indistinguishable from a prefix having been tampered away. `StudyStore` exposes this as two methods: `storageUsage(): StorageUsage`, and `evictThrough(metadata, targetBytes): StudyMetadata`, which returns the metadata unchanged when @@ -480,23 +607,57 @@ the same way. ## 9. Background execution, interventions, and recovery -- `CollectionService` runs as a `specialUse` foreground service on start and resume. The `location` - service type is added when a location collector is present and fine location has been granted. +- Start and Resume ask `CollectionService` to enter the foreground and wait up to five seconds for + its acknowledgement. The service acknowledges only after Android accepts the app-authored + notification and requested foreground-service types; collectors start or resume only after that + acknowledgement. +- A service intent redelivered from a prior process first enters the foreground as `specialUse` with + a short-lived, app-authored neutral restoration notification that contains no study title. After + session initialization it revalidates durable `RUNNING` state and current access, then replaces + that notification through a fresh acknowledged start with the exact service types and reconciles + collectors, or removes it while stopping the stale service without activating them. +- The access monitor waits 25 seconds between reconciliation attempts. It covers notification + channels, location settings, Usage Access, keyboard state, and hardware without an Activity + callback; the exact configured location probe has a five-second deadline. The resulting nominal + code-path budget is 30 seconds, although Android scheduling can extend wall-clock detection time. +- `CollectionService` normally uses `specialUse`. When optional Location access returns, the host + first obtains and acknowledges the additional `location` type and only then starts or resumes the + Location collector. When that access is lost, the runtime first closes the affected per-collector + gate and pauses the source, then downgrades the host to `specialUse`. A failed promotion may keep + unrelated collectors running only after a non-location fallback host is acknowledged. If both + attempts fail, or a demotion fails, every event gate closes and the study enters the typed + `COLLECTION_HOST_FAILURE` safety pause. - Pause, finish, withdraw, and delete stop the foreground service. +- Whole-study safety loss closes admission and writes an app-private marker containing only the + closed reason, never a study or participant identity. Required-access loss, acknowledged-host + loss, source teardown failure/cancellation, storage failure, and an unacknowledged background-work + mutation use `REQUIRED_ACCESS_MISSING`, `COLLECTION_HOST_FAILURE`, + `COLLECTION_TEARDOWN_FAILURE`, `STORAGE_FAILURE`, and `WORK_SCHEDULING_FAILURE`, respectively. A + uniquely identified WorkManager retry carries that same reason + and completes durable `PAUSED` persistence, collector teardown, and service cleanup even after + `CollectionService` has stopped. Enqueue and cancellation are not considered complete until + WorkManager acknowledges their database operations. Recovery, Start, Resume, and running + reconciliation merge the marker with active work before opening any gate; conflicts or inspection + failures remain closed. After cleanup, a non-cancellable completion sequence clears the marker, + awaits retry retirement, and only then clears the in-memory pending state. - `BOOT_COMPLETED` triggers process-scoped session initialization. The same recovery path re-verifies the signed envelope and loads the encrypted metadata. Collectors are constructed on every initialization, but the admission gate, collector activation, and the foreground service are - restored only when the persisted state was `RUNNING`. + restored only when the persisted state was `RUNNING` and the participant-start boundary is + provable in the current boot. A boot-session change instead establishes the typed scheduling + safety pause described below. - `DailyStatusWorker` posts one low-importance notification a day while the study is `RUNNING` or `PAUSED`. It says either that collection is still running or that the study is paused and since when, and nothing else: no counts and no collector names. The title line is the application's own name rather than the study title, because this arrives every day and a lock screen is readable by whoever is holding the phone. One notification tag, so today's reminder replaces yesterday's. A - run in any other state, or with no configuration, posts nothing. Without `POST_NOTIFICATIONS` the - run succeeds without posting rather than retrying. -- `AndroidStudyWorkScheduler.scheduleDailyStatus` enqueues it as unique periodic work with a one-day - period and a one-day initial delay, from `schedule` when a study starts and from - `reschedulePendingWork` whenever a session initialises. Periodic rather than a chain: a day is far + run in any other state, or with no configuration, posts nothing. `POST_NOTIFICATIONS` is a + required setup item for every study and is rechecked before Start and Resume. The worker still + treats a later revocation defensively: that run succeeds without posting rather than retrying. +- For a `RUNNING` or `PAUSED` study, + `AndroidStudyWorkScheduler.ensureCollectionWork` enqueues the reminder as unique periodic work + with a one-day period and a one-day initial delay whenever Start, Resume, or same-boot recovery + establishes the study's work set. Periodic rather than a chain: a day is far above the 15-minute floor, so nothing is silently clamped, and the platform re-establishes periodic work across reboots. `ExistingPeriodicWorkPolicy.KEEP`, so a session initialising again does not push the next reminder a full day away. @@ -535,8 +696,17 @@ the same way. action from durable state. Survey answers validate against stable survey/question/option IDs and commit as one immutable `SURVEY_SUBMITTED` event plus terminal occurrence state. Closing the UI before that commit persists no answer or draft. -- The study deadline is a unique WorkManager job. On expiry it moves `RUNNING` or `PAUSED` to - `COMPLETED`. +- The study deadline is a unique WorkManager job measured from the one durable + `PARTICIPANT_STARTED` transition, never from Resume or process recovery. Same-boot repair uses the + participant-start monotonic clock. An active study observed under any other boot-session ID cannot + prove elapsed time and fails closed with `WORK_SCHEDULING_FAILURE`; it never falls back to wall + time and never reopens a host or collector. Every same-boot active-state ensure replaces the + deadline with its recomputed remaining duration, so time-change and process recovery also repair a + stale existing WorkSpec. An already-expired same-boot study reaches `COMPLETED` before any host or + collector is reopened. WorkManager is not the data boundary: collector and occurrence admission + compare every original observation time with the exact same-boot monotonic deadline and reject + values at or beyond it. The worker rechecks due-ness, retrying an early wake instead of completing + early; a delayed wake can postpone the visible `COMPLETED` transition but cannot widen the dataset. - `UploadWorker` is a self-renewing chain of unique one-time work rather than a `PeriodicWorkRequest`. Each link is enqueued with an initial delay of the configuration's `interval_minutes` and enqueues its successor when it finishes. The reason is that WorkManager's @@ -546,9 +716,14 @@ the same way. - Constraints are `NetworkType.UNMETERED` — `CONNECTED` when `allow_metered` is true — and `requiresBatteryNotLow`, with exponential backoff from 1 minute. - The cost of a chain is that it has no platform-side repetition to fall back on. So - `AndroidStudyWorkScheduler.reschedulePendingWork` re-establishes it whenever a session - initialises, including after a boot, with `ExistingWorkPolicy.KEEP` so a link already waiting does - not have its delay reset on every app start. + `AndroidStudyWorkScheduler.ensureCollectionWork` re-establishes it on Start, Resume, and recovery + with `ExistingWorkPolicy.KEEP` so a link already waiting does not have its + delay reset on every app start. Every WorkManager mutation is awaited; constructing a request is + not treated as proof that WorkManager committed its database transaction. +- The ensure policy is state-exact. `RUNNING` and `PAUSED` request deadline `REPLACE`, daily-status + `KEEP`, and upload `KEEP` when configured. `COMPLETED` and `WITHDRAWN` request only the undelivered + upload tail, after `cancelCollectionWork` has retired deadline, reminder, and intervention work. + Pre-start states request none. A cross-boot active plan fails before issuing any mutation. - The worker acts in `RUNNING`, `PAUSED`, `COMPLETED`, and `WITHDRAWN`. It no-ops in every other state, and when the active study is not the one the job was scheduled for. Finishing or withdrawing cancels interventions and the deadline but leaves delivery running, so a study that @@ -624,7 +799,8 @@ delivered segments first, a study under its target keeping everything, undeliver segment, the newest segment and a single-segment store never being reclaimed, and the chosen set always being a contiguous leading run. The encrypted store adds instrumentation tests, on real Android Keystore, for segment rollover, reclaiming and reloading from the new floor, appending after a reclaim without reusing a sequence, reclaimed events no longer -being readable, and a missing prefix that was not reclaimed refusing to open. +being readable, a partial unlink preserving a contiguous suffix and rejecting stale-floor rollback, +and a missing prefix that was not reclaimed refusing to open. Collector admission has two complementary checks. The runtime enforces each descriptor's `maximumEncodedEventBytes` before append, while CI executes the source, bytecode, and dependency @@ -642,13 +818,23 @@ reasons [`researcher-tools/examples/README.md`](../researcher-tools/examples/REA scrolls to the export control but does not perform an export. It has to actually run on an emulator or a device; assembling the test APK is not a device-test pass. +Access has several narrower regression layers. `StudyAccessPolicyTest` proves Notifications is +unconditionally required and shared collector capabilities are deduplicated without losing owners. +`AccessRulesTest` proves every `AccessKind` has one closed rule, compound flows have prerequisite +order, and a missing system handler has no fallback. `GooglePlayLocationSettingsProbeTest` verifies +the exact request fields and all four SettingsClient outcomes; request tests prove notification +feature selection never exposes channel IDs. `AccessCardTest` renders the Compose cards to verify app-authored manual +steps, prerequisite gating, one shared Usage Access action, and every owner shown to the +participant. + Two narrower Android regressions sit beside that UI flow. `AndroidConfigurationImportTest` proves raw-key Ed25519 demo import on Android itself, so a JCA provider-order regression cannot hide behind JVM-only protocol tests. `P2CollectorEmulatorTest` creates the five P2 plugins against real Android broadcast and `SensorManager` surfaces, validates every emitted draft against its Protocol v1 descriptor, and checks pause/resume/stop boundaries. It skips when the test device lacks gyro, light, or proximity hardware. Its explicit `p2SyntheticInputs=true` mode requires -host-side emulator injection and checks the fixed readings documented in the root README. +host-side emulator injection and checks the fixed readings documented in +[`CONTRIBUTING.md`](../CONTRIBUTING.md). Before real recruitment, a study still needs study-specific testing on the target physical devices and OEMs: permissions, background restrictions, battery, storage volume, location accuracy, Usage diff --git a/docs/threat-model.md b/docs/threat-model.md index d124327..976e6a4 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -161,13 +161,13 @@ Confirmed delivery is also what makes local data reclaimable. Once a study's sto ### State boundaries and failure behaviour -Entering `RUNNING` mints an admission epoch token that collectors must present, and events carry their original observation time. On pause, completion, or withdrawal the runtime takes a monotonic boundary, persists the transition first, then drains. Only events from the same epoch observed strictly before the boundary are still admitted, after which the epoch closes permanently. A delayed callback cannot smuggle post-pause data into the dataset. +Entering `RUNNING` mints an admission epoch token that collectors must present, and events carry their original observation time. On pause, completion, or withdrawal the runtime takes a monotonic boundary, asks sources to release their callbacks, closes the epochs, and waits for every write already admitted before the boundary. Only then does it persist the participant transition. A failed or cancelled release leaves a typed `COLLECTION_TEARDOWN_FAILURE` marker or acknowledged background retry instead of claiming a clean pause or terminal boundary. A delayed callback cannot smuggle post-pause data into the dataset, and caller cancellation cannot erase the cleanup obligation. -A storage write failure or exhausted quota force-closes the admission gate, records an incident code, and fail-closes the study to `PAUSED`. There is no ring buffer and no silent dropping of events. Reclaiming space is a different thing from either: it can only release events an endpoint has already confirmed receiving, so a quota that fills with nothing delivered stops the study rather than making room. What was released is recorded in `retainedFromSequence`, declared in every bundle's `first_sequence_number`, and stated on the participant's dashboard. Nothing that has not reached the research team is ever discarded to free space. +A storage write failure or exhausted quota force-closes every admission gate and synchronously persists a typed witness before the failing mutation returns; the study then fail-closes to `PAUSED`. Safety-critical documents use a repo-owned acknowledged atomic writer with two independently synced copies. It directory-syncs `.pending` and `.replacement`, atomically renames only `.replacement` over the base while `.pending` remains an uncertainty witness, then requires exact base readback and another parent-directory sync before acknowledging the mutation. Witness retirement happens only afterwards; a cleanup failure may conservatively block reopening but cannot make higher layers roll back an already acknowledged base. Any remaining witness, rc.5 framework-`AtomicFile` `.new`/`.bak` residue, or unknown event-directory entry blocks recovery rather than being promoted, ignored, or parsed. Only an explicit replacement with caller-supplied known bytes may durably retire all residue before beginning a new two-copy write. There is no ring buffer and no silent dropping of events. Reclaiming space is a different thing from either: it can only release events an endpoint has already confirmed receiving, so a quota that fills with nothing delivered stops the study rather than making room. The retained floor commits before physical unlink and cannot be rolled back by stale metadata; unlink stops at the first failure so recovery always sees a contiguous suffix. What was released is recorded in `retainedFromSequence`, declared in every bundle's `first_sequence_number`, and stated on the participant's dashboard. Nothing that has not reached the research team is ever discarded to free space. -Occurrence lifecycle events and survey submissions have a stronger two-record boundary: the encrypted `PTCTXN01` journal makes the event append and updated metadata recoverable as one idempotent commit. Recovery applies a one-boundary-ahead journal only after authenticating an exactly matching durable tail event. It discards a prepared journal whose event is absent, and treats a same-boundary journal as a stale leftover with main metadata authoritative. Any other boundary or content mismatch fails closed. No unverified fallback reconstructs a response from UI state. +Occurrence lifecycle events and survey submissions have a stronger two-record boundary: before touching the event log, encrypted `PTCTXN01` records the proposed successor with a synthetic `RUNNING → PAUSED / STORAGE_FAILURE` transition. Recovery authenticates an exactly matching durable tail and keeps it inside that PAUSED boundary; an absent or truncated event instead recovers the prior event boundary PAUSED. The journal remains provenance until the runtime applies the first app-owned safety reason, including after repeated process deaths. A same-boundary journal is stale only when a later acknowledged main mutation proves it so. Any other boundary or content mismatch fails closed. No unverified fallback reconstructs a response from UI state. -A corrupt segment, index gap, AEAD failure, or missing key is a hard failure, and only an incomplete trailing frame in the final segment may be recovered. Event segments missing *below* the retained floor are a hard failure too, because a prefix that disappeared without being reclaimed is indistinguishable from one that was tampered away. Main metadata must name the durable tail unless the authenticated one-event journal proves the single permitted append-recovery state; no durable count is guessed or rebuilt as fallback. An export that cannot read its whole window to the boundary fails rather than producing a partial file. Missing required access keeps a study from reaching `READY`, and a foreground service that fails to start rolls the runtime back instead of collecting. A dataset is therefore either complete over the window it declares or absent, rather than quietly partial. +A corrupt segment, index gap, AEAD failure, or missing key is a hard failure, and only an incomplete trailing frame in the final segment may be recovered. Event segments missing *below* the retained floor are a hard failure too, because a prefix that disappeared without being reclaimed is indistinguishable from one that was tampered away. Main metadata must name the durable tail unless the authenticated one-event journal proves the single permitted append-recovery state; no durable count is guessed or rebuilt as fallback. An export that cannot read its whole window to the boundary fails rather than producing a partial file. Missing required access at setup leaves the study in `ACCESS_SETUP`; a failed first-Start preflight leaves it `READY`, and a failed Resume preflight leaves it `PAUSED`. Only required access lost after durable `RUNNING` produces the authenticated `RUNNING → PAUSED / REQUIRED_ACCESS_MISSING` transition. A foreground service that fails to start similarly leaves or returns the runtime to its non-collecting boundary. A dataset is therefore either complete over the window it declares or absent, rather than quietly partial. **When an event payload is authenticated, and when it is not.** Normal study opening decrypts no events. The sequence number is stored unencrypted at the front of each frame, so framing, segment index, and contiguity are checked from plaintext headers. Metadata — including each collector's last event — has its own AES-GCM tag. The only exception is the unique crash state where a one-boundary-ahead journal and a complete event tail are both durable. Opening then decrypts and authenticates exactly that tail before applying the journal. This keeps open cost linear in frames with no per-event crypto; recovery adds at most one event decrypt. **An event payload's authentication tag is otherwise verified when that event is read.** Corruption or tampering inside another event body surfaces on export or upload as a hard failure at that point. Nothing is accepted unverified, and a tampered event still cannot reach a bundle. But detection for non-recovery-tail events is deferred, so a device holding a damaged log can look healthy until its data is next read. @@ -199,9 +199,9 @@ A build that pins its signers removes this exposure for the studies it accepts, - The bundle header exposes the researcher key ID in cleartext, and the suggested export filename contains the study ID and an export timestamp. Anyone handling the file can tell that this person participated in that study. - Size discloses roughly how much data was collected. The same is true on the device: event segment file sizes and modification times leak collection volume and timing to anyone with filesystem read access, without any decryption. -**What a notification discloses to someone holding the phone.** Three kinds of notification are visible without any decryption. The ongoing collection notification is present only while collection is actually running, and it carries the study title on its second line. An intervention notification is posted only while the study is running, one per occurrence. Its title and message are the researcher's own text out of the signed configuration, so a survey prompt is on the screen in whatever words the researcher chose. Its channel is `IMPORTANCE_DEFAULT`, so unlike the other two it alerts. The third is the daily status reminder. Once a study has started, `DailyStatusWorker` posts one notification a day for as long as the study is either collecting or paused. It is the only notification the app shows while a study is paused, because pausing stops the foreground service and cancels every intervention notification. Its title line is the application's own name; its body says either that collection continues or that the study is paused and since when. It carries no study title, no researcher name or contact, no counts, and no collector names. A bystander therefore learns that this phone runs Particeps and which of those two states it is in — not which study, not what that study records, and not who is running it. The channel is `IMPORTANCE_LOW`, so the reminder is silent. One notification tag is reused, so today's replaces yesterday's rather than accumulating. If the participant never granted the notification permission, nothing is posted at all. The app sets no lockscreen visibility on either the channel or the notification, so the device's own setting for notification content on a locked screen is what decides whether the text can be read without unlocking. +**What a notification discloses to someone holding the phone.** Three ordinary notification purposes are visible without any decryption. The ongoing collection notification is present only while collection is actually running, and it carries the study title on its second line. The same channel may briefly show an app-authored neutral restoration notification after Android redelivers an old service intent; that variant contains no study title and is replaced only after revalidation or removed when the stale service stops. An intervention notification is posted only while the study is running, one per occurrence. Its title and message are the researcher's own text out of the signed configuration, so a survey prompt is on the screen in whatever words the researcher chose. Its channel is `IMPORTANCE_DEFAULT`, so unlike the other two it alerts. The third purpose is the daily status reminder. Once a study has started, `DailyStatusWorker` posts one notification a day for as long as the study is either collecting or paused. It is the only notification the app shows while a study is paused, because pausing stops the foreground service and cancels every intervention notification. Its title line is the application's own name; its body says either that collection continues or that the study is paused and since when. It carries no study title, no researcher name or contact, no counts, and no collector names. A bystander therefore learns that this phone runs Particeps and which of those two states it is in — not which study, not what that study records, and not who is running it. The channel is `IMPORTANCE_LOW`, so the reminder is silent. One notification tag is reused, so today's replaces yesterday's rather than accumulating. Notification permission and the required channels are setup prerequisites; without them a study cannot start, and revoking or disabling one while running triggers a fail-closed pause at the next access reconciliation. The app sets no lockscreen visibility on either the channel or the notification, so the device's own setting for notification content on a locked screen is what decides whether the text can be read without unlocking. -The residual risk is that the existence and the duration of a study become visible to whoever holds the phone. That is the cost of the reminder rather than a defect in it: a pause that nothing mentions is how a study meant to run for a fortnight quietly records nothing. But it is a standing daily disclosure for the study's whole length, and it is the one surface that goes on disclosing after a participant has paused. Starting or stopping collection retracts a reminder that is already showing, and finishing or withdrawing cancels both the schedule and the notification, so none of it outlives the study. Android's per-channel notification settings let a participant turn the reminder off; that removes the reminder, not the study. +The residual risk is that the existence and the duration of a study become visible to whoever holds the phone. That is the cost of the reminder rather than a defect in it: a pause that nothing mentions is how a study meant to run for a fortnight quietly records nothing. But it is a standing daily disclosure for the study's whole length, and it is the one surface that goes on disclosing after a participant has paused. Starting or stopping collection retracts a reminder that is already showing, and finishing or withdrawing cancels both the schedule and the notification, so none of it outlives the study. Android still lets a participant disable a channel, but Particeps treats a required channel as missing access and pauses collection rather than silently continuing without the promised status surface. **A compromised or hostile upload endpoint.** An endpoint that is taken over, misconfigured, or logging more than intended still cannot read a bundle without the researcher private key. It does learn the untrusted bundle metadata above, including a stable configuration digest that can link submissions from the same issued artifact. It can refuse delivery indefinitely; the device retains the data and collection continues. Conversely, an endpoint can fabricate a matching receipt without keeping the body. That can advance the watermark and eventually make those events reclaimable under storage pressure. Receipt matching makes response loss and accidental mismatch safe; it cannot prove remote durability against the server itself. Treat the endpoint as study infrastructure, keep the decryption key off it, minimize logs, and state its operator in consent material. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e85e0c..88ce346 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ kotlin = "2.4.10" compose-bom = "2026.06.01" activity-compose = "1.13.0" coroutines = "1.11.0" +concurrent-futures = "1.3.0" junit4 = "4.13.2" androidx-test-runner = "1.7.0" androidx-test-junit = "1.3.0" @@ -28,6 +29,7 @@ coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-androi coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "coroutines" } coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +concurrent-futures = { module = "androidx.concurrent:concurrent-futures", version.ref = "concurrent-futures" } junit4 = { module = "junit:junit", version.ref = "junit4" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" } diff --git a/particeps-analysis/src/particeps_analysis/bundle.py b/particeps-analysis/src/particeps_analysis/bundle.py index ba1baec..d2e73f0 100644 --- a/particeps-analysis/src/particeps_analysis/bundle.py +++ b/particeps-analysis/src/particeps_analysis/bundle.py @@ -97,7 +97,11 @@ "PARTICIPANT_FINISHED_EARLY": "COMPLETED", "STUDY_DURATION_ELAPSED": "COMPLETED", "PARTICIPANT_WITHDREW": "WITHDRAWN", + "COLLECTION_HOST_FAILURE": "PAUSED", + "COLLECTION_TEARDOWN_FAILURE": "PAUSED", + "REQUIRED_ACCESS_MISSING": "PAUSED", "STORAGE_FAILURE": "PAUSED", + "WORK_SCHEDULING_FAILURE": "PAUSED", } ALLOWED_TRANSITIONS = { "IMPORTED": {"CONFIG_VERIFIED", "WITHDRAWN"}, diff --git a/particeps-analysis/tests/test_conformance.py b/particeps-analysis/tests/test_conformance.py index 37b658c..5e9b060 100644 --- a/particeps-analysis/tests/test_conformance.py +++ b/particeps-analysis/tests/test_conformance.py @@ -95,18 +95,9 @@ def test_every_hostile_vector_is_consumed_and_rejected(self) -> None: def test_manual_export_streams_events_and_receiver_origin_rejects_it(self) -> None: bundle = self.corpus["valid"]["bundle"] - encoded = bytes.fromhex(bundle["container_hex"]) document = json.loads(bytes.fromhex(bundle["document_jcs_utf8_hex"])) document["bundle_kind"] = "manual_export" - key_length = int.from_bytes(encoded[56:58], "big") - ciphertext_start = 70 + key_length + 80 - manual = encoded[:ciphertext_start] + AESGCM( - bytes.fromhex(bundle["content_key_hex"]) - ).encrypt( - bytes.fromhex(bundle["content_nonce_hex"]), - canonicalize(document), - bytes.fromhex(bundle["context_jcs_utf8_hex"]), - ) + manual = self._reseal_document(document) with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) path = root / "manual.partexp" @@ -145,6 +136,80 @@ def test_manual_export_streams_events_and_receiver_origin_rejects_it(self) -> No with self.assertRaises(ValidationError): verifier.verify(receiver) + def test_required_access_loss_accepts_running_to_paused_bundle(self) -> None: + bundle = self.corpus["valid"]["bundle"] + document = json.loads(bytes.fromhex(bundle["document_jcs_utf8_hex"])) + self.assertEqual("PAUSED", document["experiment"]["state"]) + self.assertEqual( + { + "from": "RUNNING", + "reason": "REQUIRED_ACCESS_MISSING", + "to": "PAUSED", + }, + { + key: document["experiment"]["transitions"][-1][key] + for key in ("from", "reason", "to") + }, + ) + + verified = self._verify_container(bytes.fromhex(bundle["container_hex"])) + try: + self.assertEqual(verified.event_count, len(verified.events)) + finally: + verified.events.close() + + def test_collection_host_failure_accepts_running_to_paused_bundle(self) -> None: + bundle = self.corpus["valid"]["bundle"] + document = json.loads(bytes.fromhex(bundle["document_jcs_utf8_hex"])) + document["experiment"]["transitions"][-1]["reason"] = ( + "COLLECTION_HOST_FAILURE" + ) + + verified = self._verify_container(self._reseal_document(document)) + try: + self.assertEqual(verified.event_count, len(verified.events)) + finally: + verified.events.close() + + def test_collection_teardown_failure_accepts_running_to_paused_bundle(self) -> None: + bundle = self.corpus["valid"]["bundle"] + document = json.loads(bytes.fromhex(bundle["document_jcs_utf8_hex"])) + document["experiment"]["transitions"][-1]["reason"] = ( + "COLLECTION_TEARDOWN_FAILURE" + ) + + verified = self._verify_container(self._reseal_document(document)) + try: + self.assertEqual(verified.event_count, len(verified.events)) + finally: + verified.events.close() + + def test_work_scheduling_failure_accepts_running_to_paused_bundle(self) -> None: + bundle = self.corpus["valid"]["bundle"] + document = json.loads(bytes.fromhex(bundle["document_jcs_utf8_hex"])) + document["experiment"]["transitions"][-1]["reason"] = ( + "WORK_SCHEDULING_FAILURE" + ) + + verified = self._verify_container(self._reseal_document(document)) + try: + self.assertEqual(verified.event_count, len(verified.events)) + finally: + verified.events.close() + + def _reseal_document(self, document: object) -> bytes: + bundle = self.corpus["valid"]["bundle"] + encoded = bytes.fromhex(bundle["container_hex"]) + key_length = int.from_bytes(encoded[56:58], "big") + ciphertext_start = 70 + key_length + 80 + return encoded[:ciphertext_start] + AESGCM( + bytes.fromhex(bundle["content_key_hex"]) + ).encrypt( + bytes.fromhex(bundle["content_nonce_hex"]), + canonicalize(document), + bytes.fromhex(bundle["context_jcs_utf8_hex"]), + ) + def _verify_container(self, encoded: bytes): bundle = self.corpus["valid"]["bundle"] with tempfile.TemporaryDirectory() as temporary: diff --git a/protocol/v1/README.md b/protocol/v1/README.md index 9e0ab28..ce17421 100644 --- a/protocol/v1/README.md +++ b/protocol/v1/README.md @@ -202,6 +202,42 @@ strictly contiguous and cover that exact range. An automatic upload is never emp manual export, `event_count` is `"0"`, `last_sequence_number = first_sequence_number - 1`, and `events` is empty. +`state` is exactly one of `IMPORTED`, `CONFIG_VERIFIED`, `CONSENT_PENDING`, `ACCESS_SETUP`, +`READY`, `RUNNING`, `PAUSED`, `COMPLETED`, or `WITHDRAWN`. `transitions` is an ordered array of +exact `{from, reason, time, to}` objects. `from` and `to` are states. `time` has exactly +`boot_session_id`, `monotonic_time_nanos`, and `wall_time_utc_millis`; the two times are canonical +non-negative decimal strings and the boot-session ID is 1–128 UTF-8 bytes. + +Every transition reason has one normative destination: + +| `reason` | Required `to` | +| --- | --- | +| `CONFIGURATION_SIGNATURE_VERIFIED` | `CONFIG_VERIFIED` | +| `CONSENT_REVIEW_OPENED` | `CONSENT_PENDING` | +| `CONSENT_ACCEPTED` | `ACCESS_SETUP` | +| `ACCESS_PREFLIGHT_PASSED` | `READY` | +| `PARTICIPANT_STARTED` | `RUNNING` | +| `PARTICIPANT_PAUSED` | `PAUSED` | +| `PARTICIPANT_RESUMED` | `RUNNING` | +| `PARTICIPANT_FINISHED_EARLY` | `COMPLETED` | +| `STUDY_DURATION_ELAPSED` | `COMPLETED` | +| `PARTICIPANT_WITHDREW` | `WITHDRAWN` | +| `REQUIRED_ACCESS_MISSING` | `PAUSED` | +| `COLLECTION_HOST_FAILURE` | `PAUSED` | +| `WORK_SCHEDULING_FAILURE` | `PAUSED` | +| `COLLECTION_TEARDOWN_FAILURE` | `PAUSED` | +| `STORAGE_FAILURE` | `PAUSED` | + +The legal state graph is `IMPORTED -> {CONFIG_VERIFIED, WITHDRAWN}`, +`CONFIG_VERIFIED -> {CONSENT_PENDING, WITHDRAWN}`, +`CONSENT_PENDING -> {ACCESS_SETUP, WITHDRAWN}`, `ACCESS_SETUP -> {READY, WITHDRAWN}`, +`READY -> {RUNNING, WITHDRAWN}`, `RUNNING -> {PAUSED, COMPLETED, WITHDRAWN}`, +`PAUSED -> {RUNNING, COMPLETED, WITHDRAWN}`, `COMPLETED -> {WITHDRAWN}`, and no transition out +of `WITHDRAWN`. An empty history is legal only when `state` is `IMPORTED`. Otherwise the first +`from` is `IMPORTED`, every later `from` equals the preceding `to`, each pair belongs to this graph, +each reason has the destination above, and the final `to` equals `state`. A reader rejects the +whole authenticated document when any one of these conditions fails. + Each event has exactly `sequence_number`, `collector_id`, `payload_schema_version`, `observed_time`, `payload_type`, and `fields`. Sequence, `wall_time_utc_millis`, and `monotonic_time_nanos` are canonical decimal strings; `boot_session_id` is 1–128 UTF-8 bytes. The diff --git a/protocol/v1/collector-catalog.json b/protocol/v1/collector-catalog.json index a93cdb4..222312a 100644 --- a/protocol/v1/collector-catalog.json +++ b/protocol/v1/collector-catalog.json @@ -690,11 +690,15 @@ "access": [ { "kind": "BACKGROUND_LOCATION", - "mode": "runtime_permission" + "mode": "participant_setting" }, { "kind": "FINE_LOCATION", "mode": "runtime_permission" + }, + { + "kind": "LOCATION_SERVICES", + "mode": "participant_setting" } ], "configuration": { diff --git a/protocol/v1/conformance-vectors.json b/protocol/v1/conformance-vectors.json index a01fab0..dc44bb2 100644 --- a/protocol/v1/conformance-vectors.json +++ b/protocol/v1/conformance-vectors.json @@ -167,203 +167,203 @@ "entrypoint": "bundle", "expected_failure": "retired_product_magic", "id": "bundle-retired-product-magic", - "input_hex": "414443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1b766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfef94177bb023d9d7c5100833a3118c84c3622cffdfcf9ef269ab3301b3f743188f72cdcdec411e43f9e3b6ff4726081181a1862d419cec048c04a6740f6bc3dc40971f31f3d75eb2876381215eae53ba1bc3df6ff2732974b93e28dd4b3aebfefc9ef64b302a500b3ba6d9d2b1e80bd41e9e01083165b0ce61ae0568bfdd0636e8bb172c54541023f120fbd80918f5db8b1c22ba347f037bffc938708f0a186b1ff66d42027050f609b06bc367c43ab77b92b476c06dbe812f32f902dc3aa0adbda55006565875cc089e30dba2a649e73a6c91fb015d1127ca004c6678f0760ab4b5a6a37666f1b5dd32a91892153769d4be8a8ee8125ea1f840ebb2dd4ca01a9be433b1c5cf441f704f02e98e2ef48daa2353cb7c7c990b451d06a99eefe79231d13744f9ca044c7212de9e4d78af50805b48c2b6afac6ec497bc9d2f3204f4f2e9852d6ec0c4ecc82728eb02858ea3ec55e60b18c723b9407de281724bfa1760b7a93bf4cb067a4f2eaf16d7fe5b39be6d560b98bf8493bfcdc226eb3ce698af1d8fa7d32166b0627652086c7e01ae8f599341888cd4825ea9fde682d4f74d8fe9aa6b9d2a845eeacb30cde228bf8f018cd6d427c3b28f5748b046efd689542d9a4f6c84130458dbabc9df9fcf2b359ce5426062b1e7efbc37335e2b498163e0ba20fcacbcbd33bfbb25439bbc715f5df6b4643d47fdf479817f5d392cc3f39717ef3ef1fe2110ed5d7988782770ef08bad7625ae4208cfd3f5cd6cc04ba19ef48a7a11795b983ce0264a20ec850e11ff63a6cc589e09f7edf46c18fd72c545661ef9eac54028d9849bec0d88942636b37632ec22e70ea5aaa9566fc489a88a4b4c15138550018178e8900db4b7cff378db2fa43db0b8e9c405586736c26ad9566d36b12431474c383d191342a226182735a225ef7aea8e6f2b0d22d2ba92c6fe645341cdfb29b1d0961d073c1ac6233bdfb53ef8352c5d7285456046f2bac77ebac039c4f5dc6225815401291ea80e5250c361b156e78c570339f5048f62c5b69ec4d797d2114c02cf0123cb62ec92f480271587f0c64d98ef0e0ab0aa2d9c713bf7b74ab2e04d8a662921ff809c9b15b4f2a190462d7b4dda99fda98511ec260d1f22a2a69ef2406d06a0fc207c793ce9a479cc19335c746d48aa29022face0af22f158e2c4369d5a9747b43d12954bb2b82e8e042178b3c24c0b494a634c4b71d106693870a84fbaac03e40591582295bb94e256d7e075da00e78a4d2ff2b3dd9c6eb125c4493612f2c74070dd0487329cfb81244ab9bcb25646e40c3229f8e73a55429c8678d1b872ed674aac356c124d734f66e1cace3e5ecf8235a7734ba37c82c173ea43a2f92bc004e471a343be6fe7f404fb5a821365f32781bfb85347d25fd441d15e577d20901f53704195e8affc03368c0b3bd60aabe9ef14e854db1c9e6cd738d19ef1d20d506f7395fd096de963fee0ab7153be49322416426318aade21f20c02d7656d7bdc9aa54c7bc65e01ae7e0a92046c37385cf24bef7b860f2bfc18f28af6e0fb55c85f1c145a2aa1367daf479c326ff49b4e5f3e658057fe42af469aa29485c8a00cfc12da2d901058ffd2ed50915e76caa917b0e67e17fc5f29157694c30d798898af7f95a0ba07d91947c27199818283e8ce3a0adde1a4432c871e5322d863afd96b2159eb014ef98c6db40d6504355584d8fc47e249039cfffe1d1265ec037820a7a50613d4276caee2974e2e7b3d1651aea7855b0dbdd5555c29ca95f0bf919e6a5b0505148c5695193153aec27bdb02ce8da800a94ab0050fdebbc4c4ff50a169b9f262fca3cef3158bc844a9850ceed6ccee5f671bce70855a8efcee9a9543636c96cbbe6bd62a8b3a50259e8bf76118439f1eedc7d4e6c4aec08fcdd00184282b3d14624c9e0bf8f9c8b9b419bbd4bf3e02025f65b0828c94c4e2d77ca2e4f2781adac547b8fb4d9c75803ab55d72241945473b17dc819b4065f76469008d39825ebe36b9723014f72e528b9c1a083ddaba882a82decc88ff7c98c56e11ab722c79498ea1ce8ee53f367c23645eb78d0df19e6eb1803c8789455c72b2f2b087dc6b96c163f3cc17aec950b38ad776897c4af84afeadae1f0f07c5fc57d6fe7f0498e97c5843aab30da8d9bd8591d52252efd3ad13447cd210fed5985165714d871771a338e4cdf3afd9f8aab2abb7abb54b3096fed9ba818898fefd88db1973808a2c9aacda2515382375738cd3198624e45695fc3f9eb7b773c6373b8cd6cb749ea40bfa42911bdc366cf807a226a6d6020c3a4e838c6b0e1a30cff1c4215533430533d900c7aec49a172647246b02ca14fffdd9758a588423ea8a0c543b77edffb2147aa1801c4303abd780b21a3767341c78cd6ae526dd33a2bcce1c0cce433f08940df280da20a226da5e1f9dfc785b921e6e4c122f87d173999bf72c7a2b1afb925147dc823e9f0db2f3f234ee67d753d8ff2fbc54844fa1db423a45bda9b800680969902026de586b9684ecf6f117a38de98f2ccb4fc30028ab0464d9b781c95b10733f7c93ad61b0f848e8041ee4af5dc0724080836c65d015ba81777aa0ba484cb05e54ccb78bc32a8663a5379672e8ec9bd85d766b482ac661942a2f129f756f806577a09982fb15a39c75230ea1813f2b0f1e7c886100575521a230adbb3d466c020c8c943d7af51df009833d4cae36b2dee8fee49ce5bb892063a98aa9db5aadfc2014abcd69860c851a51afce466e09d0a111a80a331b62bcbd90ce44cda1535e7d8ce1dd3f6aec2b759edc4a258ddb87cc17f50214063f2b8976450aa2c8f07ef7b13152cc0760359a441ab31136554b8fc901ad9e08b312bfb180de3f9cf90e93b5d15a6be4f5482a528d194ce9be31274d185dfd9a8acc9866146393780d55af30f9ede8aa16093477991f1eeb9dab270c64b586496f017c5b80fb5c796faa2e5964d33ec8b61785a2c8b4b725d2033cbeca9a76a1183fd7456628e96cc4787a2c53b04519e5257d4be45d7142b52a733c19a6fee2eb36f67129d315521499a03943bb8d25d869c2f7beecc71102e31b9be1f5c5c999a172647d6003833b5a6f89f3d1ecfc66cf0dd78959b60eaf79adecbc0d7ff6da7cdd4edd85b75d2306445a3ab6dee5d08c1d2ce4a2be5944a962bc03aecb29edc15de96c16cedb810af7d4ca841f940c7a404f7ff5b8fd208ee9009952d87d783464e8611afd05a8767cb90ec3dcd1c697bdf5eb54776aafb7e29d1125642524a7312bec6ab442eea2ab5d9e4ad2cf98108a24a4fe9847611cc5b758470bd93d1af444e7d23f1d883b8b94944191993edf45eb862d37ff1819d87b1c2b8a3bd47dc589bc36d26a2ec2190b97970a29af2fa6d89edb90fb4785142ab8b4f250b0e6a4086f7d4397665c1e4840a9d809bec3b306aa8e5530d69da39289443876b79ffabdce5d4a774acab008efcedaaa671dba891ab013dff640c9799e572e1768e49d91e7abb6110e4e50109e2ba94aa42b3891cba732061a1c341c43f8dcc6242a77f18c7beeac74e4d021454cf1ade2dfdfa61384bb8c0b51913f29657cd0139f9fbdc359424a65a5737c9e2d36d7f44d8b47e4474fbfd5919718f7e95e4fa3b5eaca5cd1f6dcb68cf559e3d3d964db726ce5ae56bc624b301c94d4083411458e8a3acfb37606397dcc70e6a4a1a42b19caaedb0fc77fd41b6bd91541a28e9b2d44130ae38749bb0c6d37a2f00186726af1259b993f7c8965a87b6fe16bb8049fbbb592fa48c90def4b61d95fc123d948ece30e489626b4e004e183dbac2f58554c9e3c3428596ff284cc8ab5f7b3dddb3736dc4212744624a0c7589c0526b3b03ce88a57bb02bcb70f8daf230a56938337f1143dac4367e61ad80e1847bb594b39ee829acd0c2e739a21cda51797a8b96e907fe29bd0dc97697538ebc4b6688e3f61e7172980afe0048d9db619b71f11e087f9a364ffa75183f19c3e6d4d9e2cb9f4379d4f5d1495a6daeefcd2c84931bf9bbce7f0f207496cbfbe28770c821f3c35ad6400808fd7c66fc4eba0a3cc03c7a25e47b8de9579553b45eabc1e09db7ef86362689f7da2352a6ec14cd9bb37387c253705a29444260a1ababa3c6f84885899a1b4189fee300136b44ffd3157ea465d5616883ae3c35ce5154c03b61a9d4a38a88a49415940e37ae0ceaaedcdcccfca399b9476d8258365a7bb08310be74b5a39bc7a7cb12429454720f74f28fc17d623b266742d8ddaafa72047c7750cc98b2c6c4c961a4" + "input_hex": "414443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1b766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfef94177bb023d9d7c5100833a3118c84c3622cffdfcf9ef269ab3301b3f743188f72cdcdec411e43f9e3b6ff4726081181a1862d419cec048c04a6740f6bc3dc40971f31f3d75eb2876381215eae53ba1bc3df6ff2732974b93e28dd4b3aebfefc9ef64b302a500b3ba6d9d2b1e80bd41e9e01083165b0ce61ae0568bfdd0636e8bb172c54541023f120fbd80918f5db8b1c22ba347f037bffc938708f0a186b1ff66d42027050f609b06bc367c43ab77b92b476c06dbe812f32f902dc3aa0adbda55006565875cc089e30dba2a649e73a6c91fb015d1127ca004c6678f0760ab4b5a6a37666f1b5dd32a91892153769d4be8a8ee8125ea1f840ebb2dd4ca01a9be433b1c5cf441f704f02e98e2ef48daa2353cb7c7c990b451d06a99eefe79231d13744f9ca044c7212de9e4d78af50805b48c2b6afac6ec497bc9d2f3204f4f2e9852d6ec0c4ecc82728eb02858ea3ec55e60b18c723b9407de281724bfa1760b7a93bf4cb067a4f2eaf16d7fe5b39be6d560b98bf8493bfcdc226eb3ce698af1d8fa7d32166b0627652086c7e01ae8f599341888cd4825ea9fde682d4f74d8fe9aa6b9d2a845eeacb30cde228bf8f018cd6d427c3b28f5748b046efd689542d9a4f6c84130458dbabc9df9fcf2b359ce5426062b1e7efbc37335e2b498163e0ba20fcacbcbd33bfbb25439bbc715f5df6b4643d47fdf479817f5d392cc3f39717ef3ef1fe2110ed5d7988782770ef08bad7625ae4208cfd3f5cd6cc04ba19ef48a7a11795b983ce0264a20ec850e11ff63a6cc589e09f7edf46c18fd72c545661ef9eac54028d9849bec0d88942636b37632ec22e70ea5aaa9566fc489a88a4b4c15138550018178e8900db4b7cff378db2fa43db0b8e9c405586736c26ad9566d36b12431474c383d191342a226182735a225ef7aea8e6f2b0d22d2ba92c6fe645341cdfb29b1d0961d073c1ac6233bdfb53ef8352c5d7285456046f2bac77ebac039c4f5dc6225815401291ea80e5250c361b156e78c570339f5048f62c5b69ec4d797d2114c02cf0123cb62ec92f480271587f0c64d98ef0e0ab0aa2d9c713bf7b74ab2e04d8a662921ff809c9b15b4f2a190462d7b4dda99fda98511ec260d1f22a2a69ef2406d06a0fc207c793ce9a479cc19335c746d48aa29022face0af22f158e2c4369d5a9747b43d12954bb2b82e8e042178b3c24c0b494a634c4b71d106693870a84fbaac03e40591582295bb94e256d7e075da00e78a4d2ff2b3dd9c6eb125c4493612f2c74070dd0487329cfb81244ab9bcb25646e40c3229f8e73a55429c8678d1b872ed674aac356c124d734f66e1cace3e5ecf8235a7734ba37c82c173ea43a2f92bc004e471a343be6fe7f404fb5a821365f32781bfb85347d25fd441d15e577d20901f53704195e8affc03368c0b3bd60aabe9ef14e854db1c9e6cd738d19ef1d20d506f7395fd096de963fee0ab7153be49322416426318aade21f20c02d7656d7bdc9aa54c7bc65e01ae7e0a92046c37385cf24bef7b860f2bfc18f28af6e0fb55c85f1c145a2aa1367daf479c326ff49b4e5f3e658057fe42af469aa29485c8a00cfc12da2d901058ffd2ed50915e76caa917b0e67e17fc5f29157694c30d798898af7f95a0ba07d91947c27199818283e8ce3a0adde1a4432c871e5322d863afd96b2159eb014ef98c6db40d6504355584d8fc47e249039cfffe1d1265ec037820a7a50613d4276caee2974e2e7b3d1651aea7855b0dbdd5555c29ca95f0bf919e6a5b0505148c5695193153aec27bdb02ce8da800a94ab0050fdebbc4c4ff50a169b9f262fca3cef3158bc844a9850ceed6ccee5f671bce70855a8efcee9a9543636c96cbbe6bd62a8b3a50259e8bf76118439f1eedc7d4e6c4aec08fcdd00184282b3d14624c9e0bf8f9c8b9b419bbd4bf3e02025f65b0828c94c4e2d77ca2e4f2781adac547b8fb4d9c75803ab55d72241945473b17dc819b4065f76469008d39825ebe36b9723014f72e528b9c1a083ddaba882a82decc88ff7c98c56e11ab722c79498ea1ce8ee53f367c23645eb78d0df19e6eb1803c8789455c72b2f2b087dc6b96c163f3cc17aec950b38ad776897c4af84afeadae1f0f07c5fc57d6fe7f0498e97c5843aab30da8d9bd8591d52252efd3ad13447cd210fed5985165714d871771a338e4cdf3afd9f8aab2abb7abb54b3096fed9ba818898fefd88db1973808a2c9aacda2515382375738cd3198624e45695fc3f9eb7b773c6373b8cd6cb749ea40bfa42911bdc366cf807a226a6d6020c3a4e838c6b0e1a30cff1c4215533430533d900c7aec49a172647246b02ca14fffdd9758a588423ea8a0c543b77edffb2147aa1801c4303abd780b21a3767341c78cd6ae526dd33a2bcce1c0cce433f08940df280da20a226da5e1f9dfc785b921e6e4c122f87d173999bf72c7a2b1afb925147dc823e9f0db2f3f234ee67d753d8ff2fbc54844fa1db423a45bda9b800680969902026de586b9684ecf6f117a38de98f2ccb4fc30028ab0464d9b781c95b10733f7c93ad61b0f848e8041ee4af5dc0724080836c65d015ba81777aa0ba484cb05e54ccb78bc32a8663a5379672e8ec9bd85d766b482ac661942a2f129f756f806577a09982fb15a39c75230ea1813f2b0f1e7c886100575521a230adbb3d466c020c8c943d7af51df009833d4cae36b2dee8fee49ce5bb892063a98aa9db5aadfc2014abcd69860c851a51afce672fb80061b7f8ead67b038c4c416f951dc1428b6c0af3d8ab2badfb516f5dcc95cc0a761c76e51474e6da2af93764cab60971f8e77121d23ce64044ab15cad3002694eb6f59f0fd8e79c2b22e80f16ecf6c3918f355343bbba47139ae4719199ce80c83e62c785c5dfa99dff8b275e21778dd541aa548dd89dba7dc34977def3efb8c1a971c141644f8bf41fff8900bac68aabbafd854c33fe856f2d4c218b785940243bf1c6a8b0562387f8745378df8ed45486a2d764555fc8491acca624db15235da92bc88978f521bc636674c26002780d8e1e9674a0ca3c8a9d2774e0d47803392ab6b1135d3a9794473143c41c397fadbe99933c03d9cd76fbd06f86846cf8ff8ac1decbdcfe0ba9c382f0dc5332ca713d05aeab76c5711ed7d2d44c2ad4a247d033d87ae1b285854ecd90d677f0e81ea1324ea940e25bc6a30ecbd4468bda32df9f069431d6cf9b564c8603a1de0f916acba3c720c9145351de49897572affb7b33800a4e52504a614defc8fd5f63f232d4d5e5b03af29b03ad5f44e3897118a90424d329f987ccad0b566542fdd99eaeb2534f160c98e7f959b107dd71a1969995adc3f4bba526d05986d5663ca9f22390bf6c74b39b94f463dff0bd07f360101beb864f3e20227c5686edd2384753cca29c12dd8d9bf7626b79aef2481039d437679642867062feacd6d9ffba70a4913181f3ecb6f769c3b992ab1333f1311a9a99d659fc728673f31f6d875314e1e50413b3a28cbb41b39b43eb7d767aecdb59a73d8dca7442ab730ed6bfefb5111c554d10db07dc62e5e2023a4bbed6b5151fe48756c87337f7abcb318638a7164f2faae0d36b6944d4bb7c5377f1f857166d847184f6fa2d5fcaabc34970cf60884ddf647d9b4dac0de24cf36bdc22b230ff40069b595455e8b8f5a02466748cc1970064051843b087b1ecb7f64bd65cb2b5ab651527e8ae855928b83c7489bec8866c22002b4c3bab1a639392e0f4a45e82b6fb0cea9851edbf593dfbdd9e88effb058d8c05399193b56fb5de3b2f5a1d4c5725a2b2e28151d498cd4cd581fb3a50c9e747634fcca07570ce2b3e556d5007798ed5416d2a02a886ab2dad2fc337e09bab70a8692318530755dade307f509b8da79c63f599b385b172bfd6d5fc24f212d41e7b7b8a8df206f9238126d4729f69bfb3446794b2ee0666779818f00e1dcfd661a85aec1a0045b03758c6471c3a19c6fc85c1fadc9a436b8ba4df1f4120b6f7bd2b9f931cfeb2ab205e067ad2cce4e3ca2b8e16c2d260f77c205fb6364bea4ab810378c653d559b08e88278fc358e6798edd7ba81d4941e0185cbe0072fe7a70cc89fa573879b40220576003f3fe6beb7b8c4f763ced79a510299e5e5175b6d52b1d51f7fe328856f6fc0a33d34c45452b23939e69dde949ba59f13d55167bf06a1bc9d9c87eeac818a6e7c99463a6a60e6da6de43ff5ed82d8e485561fd60960172bbfc3d1602f6860157ef04614052557177f6430aa2cfc7bb8ea143a1244cd21db9e906522147cbfd2a141e566c05d3cc6fdb20357efc8618c1e53a2896d2cd7335165380f54419f714278301fb16b0e50288e77ed887aa04aad0a2459618f8e2605590d012051a79c020b95a1ef025587299b34eee3228705cd03a1fef63303e3939ecb174dcb0ccfc938123dcc4d4b516f70cca86b61a0e6a3052eae4023c34e46a166b6e64a18ba816280883bf47160ce9734cb6983e428d66075350ce276045d41" }, { "category": "old_v1", "entrypoint": "bundle", "expected_failure": "retired_bundle_format", "id": "bundle-retired-bundle-format", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a22766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf86f0f3cb589dd7c867f411da54a4984ee76a418bd81687e3f0398d5932ce61988214b2fc3bf577876263ccb3e01f7ae558b618a734e919ab1e9f775ea78cc3078f518d2137292bdb2345705c3112ccb32da2c96016faa111d419bc71d1f1df58073f9843bcfc8b0553164af0003b7792689b6d3c7b76e25ea5cfb891476e28c8256fef44cd1f5f4c27e0877664ac2b43b241d208790fd4310a452995967c045a3033d7c920948ba19f43ab3fb5252e9ea9699b5da3d14000df24b3b0025e06f1b34729cc7fa418aaaf9d6da4a09d299d454262e3b64b70b8a49dd80881ee7eca94b689f832183ac3a72397d96151cd226e513f8a660a6729bc2932c4ff9b7e39f9138f43892b8063bc3b772d632d634c7c47e0d7ff4460090e7fd6c5be2303a2a345b1fca2b06fb5b3f07ff71a91353013c0e4c67f6d93160b26d4fae17216f973267e0687bfdb9276c24124900369240aaed7568fc818e6740648c7317f0eef66b6c820dbc58b2c57aa72a7e4fd876f4fffc6527a6b894803dcf3c6f9e39fb7bebb80d3acdda4d8a86007dd19755e482b842368de56d326b1a39ee4e81db64d919e39a0713ff5557afbe47a1c84a2737ca25ea69e409ead63be3c1051ec9539b7914c96323910443afe935b55c22b7e7e70fb84a3ab767bb7fb3251455770203d7c0dbbf3c27540cd2521eff7050bb0bf6d8d4a0a32bcf2cdf4ea3f06dcf693e150df8d357b95a923a00bc7846eaf9e21785ea949316079f224ccb18c764f004d5af9827f8e463e0e9190d30480c8b9cce90079c58d1ca5e6591c327f9c631a16b43961d41246bcc4ba07734606a84901f64160b6312439dece5f3a166d68c4139aa49a8226b2b4a806013f000c134bc37a65274ac7f9b7e9fad3f96d547679693aa13d9540e7622631ad466ef0733b2b00d29d1ab4cbd92e1e141174594aa9887e4004ffefe11b0f35f98ce947beb68de8ce4cf4a6d520a6a76b78f16c3812f2b5e50b4d74e34704fc7dba747f9826a62092c286bd28abc5d0892e0d988ce5e50ec3ccbbbe9a52961e8c5225196ebe4688dcbcbc93c7cd6573a8755f8134bc35b7ce031b5ed3c752aab47b000d75ee8f167ef0250baff59849e9379819d127a75635ce5de626b0963190f8e7482966ea6789366ff5b41bc7e2660a7271888352941da965b8973d0ca2ec89b04401766b39ac6eb894e605a7433ba1a27da0506a0326de071adefd69ce3b78619555b006ba67c73ade6223c6346b7c67045df421bdc863078b4cb0aa7d8bb817fb794fba12417a9cc70dc62f033ab771c09b1c73c48eda2558c0a644e5f3a79d645aac113c760edb246ffbc0135e4d50e12879c06319233f87f36e7195e3bbc9f900474736e6114a05e9780e53df359e488dcd23c415c3b0a8cc219b6531b97ab9ae1f662166965d7bae4ad85e98a2c621a8fb49d9aa2795a71751a05a002aeb2a31366bb821ded930a54332fdfd3354ff08b46d89154771fbdb568c4462273b9ee1911d1fb3906a165498babe7f31d25bcf0a3a4e35c15436f6b8c673575aea2096ecf51fd77a9a50ba2d6774bef0e96f251d770ef2818d591e01094953a80d5cfbc1c5f4eeeb1812e57fd2fc4f84c70a7d02e62bfab16b439a54c4029f3fe05eba9f5619adf2ef9de8e4de0d4e632fedf0178b185596236d38709e45632a13c93d171db46738a40963a98456d20c522a86dd351c62b56c87b7856e164312279caa5909c7079653083b5b41f349e5923b92314844b6a27b69ce19e2616ae35d7b5cc0318e080a59a045c996d9939632c9c888a5c0431684dcd2179297c43211a398719aefcae837078add8272c625843bca9a0f39b287f0d018775b28955154820a0a2d7d772b4ef7c79d45ce4edc22452ba9f2cdad0c3131715ee7e4734a34e5de449965b89254d009fa9424941102a8adf22e027aa7c608defc6ef6c7a6bf45fce378995138e4eef7dd58845b7c93c76461f6d637fe9939e01ef3ef2455fa25c3b5ba061daf3650e8cec17957116647036ac849e1063cff5503ebf7e45ed5c8b08560ce22db9231618317b876e0f5ed55c43cbdbf9643eaf6b13792c1ee910a22ddffd4c01d7cffd236417539274f3d8451a331711f16afd7aac7f206aa836cf9b9a8b917cbee81481fe04c1e61e829bbfb2ef5dba32199fbb2a78374605f53014a558caf782daa785c54388646343bbc553361442ff9909e3e39ded0b340c02e2bcc0cf5443fdbe7463bdb415b6a3baf806cfd7fa81135dd037fb1b3dd5b762341f0cd81d0b735c793fe1311fbc7183fae12a35d5bb3df20ae059edeb5a783f9c6aa0009395295859223e542e5aec13af600d22eb1069f2222cb30500338c1478ca38543dbfda2b60907e5e4db6ba947d5986353e9eb744f916f38db5550b1f2a7efe1c89ee8f86f59d0e5c1a6d7e8861745fa26b30dc7b220b79c38b29dbf13a9c41a1b812424a4f1cf44da6cf0ba20c4aab38c2590f34cadae7c56080278798040256540fd8e454126f3241a8d82f704b3d9c2d90c6d1539e8851e3939c315d79a3c5a740ae0fb049462f8b9b6dc67b72184faab973bbb2e02212062d981115e30c0f042628fd0be63abbca822e3b2ebf15772294e0e5df60303a7b17362600e11d674b37b5a7590aa5bd500248bdc46a86c1631a7d3659f573687e51de3d6e5d03427c41424348bd50780d56c0be61208b790ab2bef274c67c680a7a6b7b2fdea4dafc9a9baf8d85b5bfdfadcba2f788127c2ea726d130f68955c2eb6d3c7b157f69795f4dedb497a50714824bde7b469aad8e0ca538b79fba7650555bd5fae868759fff54fef1b5eb4a3f5efcd96d9b3434683ccb633e2ed6a3f2445ea3a9c01e47093a7943b5fe310772ecda40782dc73050b4aa76d3ad66ee9d3141a8ec93ae802b1b15616f78e4f6de62ba0de9812a87b10a55a90ee9de373b259066ec594caee21881b0b80a3d8b95f40e554f0d600097d1ec58eae176da80501f5818213e65078cdfe5206724489c368fbcd8af6a5e07aed6c80ea1b8e31d73e1197a8ef5b391372c13bfe1ecb762b200ab9a6ce87a3fc037288385558a8ad2f7f6691b070b46afb0886291b66daacb0b7dd57eff4c54e10fe9bacfefa0f9b8949d26f50ad2901bd67bd267e46634b08e1ddb660b00f6186fb2ca34fa61115cd4da30fe24b5904d0c04e9676d485e0926e3badec9920129389cf8e85683b9d93ddfc4c1eb41f234c8f845cb8463a44e634f5f8e9be2fb23cd5b46b6891f69089accb877b9c34b9d5254204107c3bbbf5cebee13a03150fca895c2b361cbfd5fc92736e14cdc247d5629bd18022b14ddd29e635397892a125210eb2f5026147a6cd676cfd6d3163e50df17db7e9234c4e5a8eb8bb47b6fc2c17805bd222d278c07b95787553bc367272170fe2ab52b12cf5daa2fddb88f1b2620c3c8c9013e44783371467588ce4b50b7f3037b0ead8cf9d15ea4f2b6107a83b903b26494b6a8e0fd9c737115969884f79cdcc4e08e93c03f93a3d00285305b0c8514bbac39f0ee9a9b92564f3d6873b5a88bc1ca02fa3b92a7ac276705e3e6ab9a675172e47b2ced0bc90b9cb6217999b9f0b80f86ba4ae58cee6bd9e66332184deb436ba4478b217f8316adfde387fd758d40fad83f07396852a8f1cfcb18cfdb5dceccb684a7bc788532994a1c430ea4ab1a17a9757456caebb8e738672d847934ca52e2313284e52c7e3c0068429dab590233a812d164d54848abc94e668f4c99fc612eff59cca7364c54cb45f227504ee271e590bfc21f97b6ab22c9d0aba9e55ed9e58d37a162118df6ea930db3532229185dd7c67a474dc382be21771e47234f07acd2e5f0a7fb1ccaf686ab1e2179445a065189999d6730db4b4b4be4c2036ae12b099001500a1f76332f1c6269022bd40bccbaddc0bda1c54a6bd365221f44d7d5447557cfabb90e895320d9697685e0a0aca601e69139cfca66ac54dd276ffaef0f3c1df7e295c1e62fc49b39b5e89e383d7c1d77b9c9edfbb10adce8169a0af08863c81de45751a420ab28d0a66b34abe907b30afcd00b9c9853cf49df17a505f69162a6f644734338f64f4f98c7a11b286cd6002bc4bfd7431078e6570b95ebe3a00cfc58a6a0d9938be42d4e871898b418731cc78ee65cd105993068a22328e5b6781e015422338b2384d2c9ecadd805516b41728f97291984973b1f0fe1e690b256ff1e222d48a4539a4a009051c13937505f4273e683345d38f54a3e7f608" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a22766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf86f0f3cb589dd7c867f411da54a4984ee76a418bd81687e3f0398d5932ce61988214b2fc3bf577876263ccb3e01f7ae558b618a734e919ab1e9f775ea78cc3078f518d2137292bdb2345705c3112ccb32da2c96016faa111d419bc71d1f1df58073f9843bcfc8b0553164af0003b7792689b6d3c7b76e25ea5cfb891476e28c8256fef44cd1f5f4c27e0877664ac2b43b241d208790fd4310a452995967c045a3033d7c920948ba19f43ab3fb5252e9ea9699b5da3d14000df24b3b0025e06f1b34729cc7fa418aaaf9d6da4a09d299d454262e3b64b70b8a49dd80881ee7eca94b689f832183ac3a72397d96151cd226e513f8a660a6729bc2932c4ff9b7e39f9138f43892b8063bc3b772d632d634c7c47e0d7ff4460090e7fd6c5be2303a2a345b1fca2b06fb5b3f07ff71a91353013c0e4c67f6d93160b26d4fae17216f973267e0687bfdb9276c24124900369240aaed7568fc818e6740648c7317f0eef66b6c820dbc58b2c57aa72a7e4fd876f4fffc6527a6b894803dcf3c6f9e39fb7bebb80d3acdda4d8a86007dd19755e482b842368de56d326b1a39ee4e81db64d919e39a0713ff5557afbe47a1c84a2737ca25ea69e409ead63be3c1051ec9539b7914c96323910443afe935b55c22b7e7e70fb84a3ab767bb7fb3251455770203d7c0dbbf3c27540cd2521eff7050bb0bf6d8d4a0a32bcf2cdf4ea3f06dcf693e150df8d357b95a923a00bc7846eaf9e21785ea949316079f224ccb18c764f004d5af9827f8e463e0e9190d30480c8b9cce90079c58d1ca5e6591c327f9c631a16b43961d41246bcc4ba07734606a84901f64160b6312439dece5f3a166d68c4139aa49a8226b2b4a806013f000c134bc37a65274ac7f9b7e9fad3f96d547679693aa13d9540e7622631ad466ef0733b2b00d29d1ab4cbd92e1e141174594aa9887e4004ffefe11b0f35f98ce947beb68de8ce4cf4a6d520a6a76b78f16c3812f2b5e50b4d74e34704fc7dba747f9826a62092c286bd28abc5d0892e0d988ce5e50ec3ccbbbe9a52961e8c5225196ebe4688dcbcbc93c7cd6573a8755f8134bc35b7ce031b5ed3c752aab47b000d75ee8f167ef0250baff59849e9379819d127a75635ce5de626b0963190f8e7482966ea6789366ff5b41bc7e2660a7271888352941da965b8973d0ca2ec89b04401766b39ac6eb894e605a7433ba1a27da0506a0326de071adefd69ce3b78619555b006ba67c73ade6223c6346b7c67045df421bdc863078b4cb0aa7d8bb817fb794fba12417a9cc70dc62f033ab771c09b1c73c48eda2558c0a644e5f3a79d645aac113c760edb246ffbc0135e4d50e12879c06319233f87f36e7195e3bbc9f900474736e6114a05e9780e53df359e488dcd23c415c3b0a8cc219b6531b97ab9ae1f662166965d7bae4ad85e98a2c621a8fb49d9aa2795a71751a05a002aeb2a31366bb821ded930a54332fdfd3354ff08b46d89154771fbdb568c4462273b9ee1911d1fb3906a165498babe7f31d25bcf0a3a4e35c15436f6b8c673575aea2096ecf51fd77a9a50ba2d6774bef0e96f251d770ef2818d591e01094953a80d5cfbc1c5f4eeeb1812e57fd2fc4f84c70a7d02e62bfab16b439a54c4029f3fe05eba9f5619adf2ef9de8e4de0d4e632fedf0178b185596236d38709e45632a13c93d171db46738a40963a98456d20c522a86dd351c62b56c87b7856e164312279caa5909c7079653083b5b41f349e5923b92314844b6a27b69ce19e2616ae35d7b5cc0318e080a59a045c996d9939632c9c888a5c0431684dcd2179297c43211a398719aefcae837078add8272c625843bca9a0f39b287f0d018775b28955154820a0a2d7d772b4ef7c79d45ce4edc22452ba9f2cdad0c3131715ee7e4734a34e5de449965b89254d009fa9424941102a8adf22e027aa7c608defc6ef6c7a6bf45fce378995138e4eef7dd58845b7c93c76461f6d637fe9939e01ef3ef2455fa25c3b5ba061daf3650e8cec17957116647036ac849e1063cff5503ebf7e45ed5c8b08560ce22db9231618317b876e0f5ed55c43cbdbf9643eaf6b13792c1ee910a22ddffd4c01d7cffd236417539274f3d8451a331711f16afd7aac7f206aa836cf9b9a8b917cbee81481fe04c1e61e829bbfb2ef5dba32199fbb2a78374605f53014a558caf782daa785c54388646343bbc553361442ff9909e3e39ded0b340c02e2bcc0cf5443fdbe7463bdb415b6a3baf806cfd7fa81135dd037fb1b3dd5b762341f0cd81d0b735c793fe1311fbc7183fae12a35d5bb3df20ae059edeb5a783f9c6aa0009395295859223e542e5aec13af600d22eb1069f2222cb30500338c1478ca38543dbfda2b60907e5e4db6ba947d5986353e9eb744f916f38db5550b1f2a7efe1c89ee8f86f59d0e5c1a6d7e8861745fa26b30dc7b220b79c38b29dbf13a9c41a1b812424a4f1cf44da6cf0ba20c4aab38c2590f34cadae7c56080278798040256540fd8e454126f3241a8d82f704b3d9c2d90c6d1539e8851e3939c315d79a3c5a740ae0fb049462f8b9b6dc67b72184faab973bbb2e02212062d981115e30c0f042628fd0be63abbca822e3b2ebf15772294e0e5df60303a7b17362600e11d674b37b5a7590aa5bd500248bdc46a86c1631a7d3659f573687e51de3d6e5d03427c41424348bd50780d56c0be61208b790ab2bef274c67c680a7a6b7b2fdea4dafc9a9baf8d85b5bfdfadcba2f788125d6f16f61196a669b0a28a5dcdaab4aeb9194e98fc3285a09355c39bfa8ac71c1dcfdd54e8d68fac16b0b05aa5bbc9a8615e7ed2ee31a56bbade7e8de83c4b542578fd6b83aedf86b38335fe3298b1aeb7f9fa6f23551b50d73268abc46db9c7e0510618b7b2cd674efd2252c838f22f042bcb14d4fac9d497afd36f0d0965daa7a11be4191e997df58af5d0e54f49bc5ef3dd9031393a2d8ab514eb042fdd63322601acdb484167a943705f0818709b74860deff52142d15879573b6d59297a9e172e26298e308992ad8311d96ceb00a6e4a36d526fc51d36e4a2c0bb1a9c09faaef146987375959cea3212f7195a26cb526e310e7251a7bcca7aabcd040fcebc95c18ee84b9f5f10efd8747847254a56e19fc3efd2b7e5d48671ef7ddac66b13e578bbd34bb0fab110e9416b009f5504454dece019477d59efb93693191c784241aa9b8c08184746a858bcdfe4c0cba11765a82846f935b3e4cdc1ef4efd58c2bb73cd0ae3a7089e69289be94d675ca2ff4cd3d230811612db0efc5b1f431091808c3ec037a6145fbc1e1903c760cacce46c87490cb8b2da446d724e13c5c769cf132251caef44e795fc7c16671eb662b68fb0ff17ba2ed324d285480eea643bebb3456d91bdf22c953ec6d83786f55bd07447f5117faeb5fb137ac81b1fbcc93ece26c02738e9112ff5c82301e5b7391e0bd314e3f38b1f689d78504e94f396f09fd2d9d3b1562566e8635f3c6202d6b6d8d4f7cd79d5610f83f03eb656c0e7e4848a8d03249bac5890ee5a5af3465f6a4d86a0dd1f808bd2deca13219c07676483e66b5b064162b35bcc080ab94abd7635b8183fc0980fe7da4a257ccf1be94633d2e98d5bb27a8446eb371f63f3cc2da3038cf198d4fa083eb58ba933c8f06fab0bdcbb89af4d328477bdcd1083a92b6df2dba44bfee7896565e77afbc844fad6fdc4fa97daa21220f79564ad1e7c0148a278fa39d2309aa3012456eae8baba8d46cf1c99adc43f7ed8ace73769a1dba0939381cf657095d0ee15aa62a3deb688917b8d14df5ee4fd77f0b5a16d13ebe34c929336e899daf6d74a269d63232f3186bef7e33e569cb3f5e6c71bf9ab26c62f6fa56cd05ad6503b2b5c0650daeb2b58f7a2d70b60af094000e59fae46525eadb769e2cf242bdcab6c70add16688da0325a1bc54272555b0464e2ac95e8873c03c381655e3921d7641653399deb9a58c148d273e5ffe8ebd6da7e3b034f6caa52fe8346f9e498d7c6d072f9c18e9d1ee9c99e68edf44eb10d90e46449321541f8a01c62b150b5dc2277dfb273dca2aa5592a7c8497c684e20117d4c60380fb54089b8c76214b690cd605baf19a22b757ad12265a545bc352b849b8a224a8832b0449af639888a40917f8577f53b81530f8c1a8c2518995778c68500012e3ba87f197af28c88c20453f90e6baf3cf9ce0c3ca4c3ea1e6c3b3a6bbfb469c6c61b69f9f95d1743130b8d4c224d194e337a3168ed399edf06fd288795f160212465441711dda1f2f4d225ce0db29d1f625a03cfc6967485750670a03c7e8a49ebf542c81055e79d0a698c75e75c30554b547bf743a789528838caf92b7aaf9308ddcf91acae76a3e9898e9bb399f9d0fa0e2763246f31ecc86eceebf0268eb259a8d86ac42a50cfa4e87e90fbddf616b118295de3a5c9991c68fe3c7d6d4d9d6fe18b0e2d826253ff965b9b87944ac52820b17f1f75fb41c2f85fde37f70ebb29" }, { "category": "outer_inner_identity", "entrypoint": "bundle", "expected_failure": "configuration_digest_mismatch", "id": "bundle-wrong-configuration-digest", - "input_hex": "505443455850303100000000000040008000000000000099fa2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1b766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfef94177bb023d9d7c5100833a3118c84c3622cffdfcf9ef269ab3301b3f743188f72cdcdec411e43f9e3b6ff4726081181a1862d419cec048c04a6740f6bc3dc40971f31f3d75eb2876381215eae53ba1bc3df6ff2732974b93e28dd4b3aebfefc9ef64b302a500b3ba6d9d2b1e80bd41e9e01083165b0ce61ae0568bfdd0636e8bb172c54541023f120fbd80918f5db8b1c22ba347f037bffc938708f0a186b1ff66d42027050f609b06bc367c43ab77b92b476c06dbe812f32f902dc3aa0adbda55006565875cc089e30dba2a649e73a6c91fb015d1127ca004c6678f0760ab4b5a6a37666f1b5dd32a91892153769d4be8a8ee8125ea1f840ebb2dd4ca01a9be433b1c5cf441f704f02e98e2ef48daa2353cb7c7c990b451d06a99eefe79231d13744f9ca044c7212de9e4d78af50805b48c2b6afac6ec497bc9d2f3204f4f2e9852d6ec0c4ecc82728eb02858ea3ec55e60b18c723b9407de281724bfa1760b7a93bf4cb067a4f2eaf16d7fe5b39be6d560b98bf8493bfcdc226eb3ce698af1d8fa7d32166b0627652086c7e01ae8f599341888cd4825ea9fde682d4f74d8fe9aa6b9d2a845eeacb30cde228bf8f018cd6d427c3b28f5748b046efd689542d9a4f6c84130458dbabc9df9fcf2b359ce5426062b1e7efbc37335e2b498163e0ba20fcacbcbd33bfbb25439bbc715f5df6b4643d47fdf479817f5d392cc3f39717ef3ef1fe2110ed5d7988782770ef08bad7625ae4208cfd3f5cd6cc04ba19ef48a7a11795b983ce0264a20ec850e11ff63a6cc589e09f7edf46c18fd72c545661ef9eac54028d9849bec0d88942636b37632ec22e70ea5aaa9566fc489a88a4b4c15138550018178e8900db4b7cff378db2fa43db0b8e9c405586736c26ad9566d36b12431474c383d191342a226182735a225ef7aea8e6f2b0d22d2ba92c6fe645341cdfb29b1d0961d073c1ac6233bdfb53ef8352c5d7285456046f2bac77ebac039c4f5dc6225815401291ea80e5250c361b156e78c570339f5048f62c5b69ec4d797d2114c02cf0123cb62ec92f480271587f0c64d98ef0e0ab0aa2d9c713bf7b74ab2e04d8a662921ff809c9b15b4f2a190462d7b4dda99fda98511ec260d1f22a2a69ef2406d06a0fc207c793ce9a479cc19335c746d48aa29022face0af22f158e2c4369d5a9747b43d12954bb2b82e8e042178b3c24c0b494a634c4b71d106693870a84fbaac03e40591582295bb94e256d7e075da00e78a4d2ff2b3dd9c6eb125c4493612f2c74070dd0487329cfb81244ab9bcb25646e40c3229f8e73a55429c8678d1b872ed674aac356c124d734f66e1cace3e5ecf8235a7734ba37c82c173ea43a2f92bc004e471a343be6fe7f404fb5a821365f32781bfb85347d25fd441d15e577d20901f53704195e8affc03368c0b3bd60aabe9ef14e854db1c9e6cd738d19ef1d20d506f7395fd096de963fee0ab7153be49322416426318aade21f20c02d7656d7bdc9aa54c7bc65e01ae7e0a92046c37385cf24bef7b860f2bfc18f28af6e0fb55c85f1c145a2aa1367daf479c326ff49b4e5f3e658057fe42af469aa29485c8a00cfc12da2d901058ffd2ed50915e76caa917b0e67e17fc5f29157694c30d798898af7f95a0ba07d91947c27199818283e8ce3a0adde1a4432c871e5322d863afd96b2159eb014ef98c6db40d6504355584d8fc47e249039cfffe1d1265ec037820a7a50613d4276caee2974e2e7b3d1651aea7855b0dbdd5555c29ca95f0bf919e6a5b0505148c5695193153aec27bdb02ce8da800a94ab0050fdebbc4c4ff50a169b9f262fca3cef3158bc844a9850ceed6ccee5f671bce70855a8efcee9a9543636c96cbbe6bd62a8b3a50259e8bf76118439f1eedc7d4e6c4aec08fcdd00184282b3d14624c9e0bf8f9c8b9b419bbd4bf3e02025f65b0828c94c4e2d77ca2e4f2781adac547b8fb4d9c75803ab55d72241945473b17dc819b4065f76469008d39825ebe36b9723014f72e528b9c1a083ddaba882a82decc88ff7c98c56e11ab722c79498ea1ce8ee53f367c23645eb78d0df19e6eb1803c8789455c72b2f2b087dc6b96c163f3cc17aec950b38ad776897c4af84afeadae1f0f07c5fc57d6fe7f0498e97c5843aab30da8d9bd8591d52252efd3ad13447cd210fed5985165714d871771a338e4cdf3afd9f8aab2abb7abb54b3096fed9ba818898fefd88db1973808a2c9aacda2515382375738cd3198624e45695fc3f9eb7b773c6373b8cd6cb749ea40bfa42911bdc366cf807a226a6d6020c3a4e838c6b0e1a30cff1c4215533430533d900c7aec49a172647246b02ca14fffdd9758a588423ea8a0c543b77edffb2147aa1801c4303abd780b21a3767341c78cd6ae526dd33a2bcce1c0cce433f08940df280da20a226da5e1f9dfc785b921e6e4c122f87d173999bf72c7a2b1afb925147dc823e9f0db2f3f234ee67d753d8ff2fbc54844fa1db423a45bda9b800680969902026de586b9684ecf6f117a38de98f2ccb4fc30028ab0464d9b781c95b10733f7c93ad61b0f848e8041ee4af5dc0724080836c65d015ba81777aa0ba484cb05e54ccb78bc32a8663a5379672e8ec9bd85d766b482ac661942a2f129f756f806577a09982fb15a39c75230ea1813f2b0f1e7c886100575521a230adbb3d466c020c8c943d7af51df009833d4cae36b2dee8fee49ce5bb892063a98aa9db5aadfc2014abcd69860c851a51afce466e09d0a111a80a331b62bcbd90ce44cda1535e7d8ce1dd3f6aec2b759edc4a258ddb87cc17f50214063f2b8976450aa2c8f07ef7b13152cc0760359a441ab31136554b8fc901ad9e08b312bfb180de3f9cf90e93b5d15a6be4f5482a528d194ce9be31274d185dfd9a8acc9866146393780d55af30f9ede8aa16093477991f1eeb9dab270c64b586496f017c5b80fb5c796faa2e5964d33ec8b61785a2c8b4b725d2033cbeca9a76a1183fd7456628e96cc4787a2c53b04519e5257d4be45d7142b52a733c19a6fee2eb36f67129d315521499a03943bb8d25d869c2f7beecc71102e31b9be1f5c5c999a172647d6003833b5a6f89f3d1ecfc66cf0dd78959b60eaf79adecbc0d7ff6da7cdd4edd85b75d2306445a3ab6dee5d08c1d2ce4a2be5944a962bc03aecb29edc15de96c16cedb810af7d4ca841f940c7a404f7ff5b8fd208ee9009952d87d783464e8611afd05a8767cb90ec3dcd1c697bdf5eb54776aafb7e29d1125642524a7312bec6ab442eea2ab5d9e4ad2cf98108a24a4fe9847611cc5b758470bd93d1af444e7d23f1d883b8b94944191993edf45eb862d37ff1819d87b1c2b8a3bd47dc589bc36d26a2ec2190b97970a29af2fa6d89edb90fb4785142ab8b4f250b0e6a4086f7d4397665c1e4840a9d809bec3b306aa8e5530d69da39289443876b79ffabdce5d4a774acab008efcedaaa671dba891ab013dff640c9799e572e1768e49d91e7abb6110e4e50109e2ba94aa42b3891cba732061a1c341c43f8dcc6242a77f18c7beeac74e4d021454cf1ade2dfdfa61384bb8c0b51913f29657cd0139f9fbdc359424a65a5737c9e2d36d7f44d8b47e4474fbfd5919718f7e95e4fa3b5eaca5cd1f6dcb68cf559e3d3d964db726ce5ae56bc624b301c94d4083411458e8a3acfb37606397dcc70e6a4a1a42b19caaedb0fc77fd41b6bd91541a28e9b2d44130ae38749bb0c6d37a2f00186726af1259b993f7c8965a87b6fe16bb8049fbbb592fa48c90def4b61d95fc123d948ece30e489626b4e004e183dbac2f58554c9e3c3428596ff284cc8ab5f7b3dddb3736dc4212744624a0c7589c0526b3b03ce88a57bb02bcb70f8daf230a56938337f1143dac4367e61ad80e1847bb594b39ee829acd0c2e739a21cda51797a8b96e907fe29bd0dc97697538ebc4b6688e3f61e7172980afe0048d9db619b71f11e087f9a364ffa75183f19c3e6d4d9e2cb9f4379d4f5d1495a6daeefcd2c84931bf9bbce7f0f207496cbfbe28770c821f3c35ad6400808fd7c66fc4eba0a3cc03c7a25e47b8de9579553b45eabc1e09db7ef86362689f7da2352a6ec14cd9bb37387c253705a29444260a1ababa3c6f84885899a1b4189fee300136b44ffd3157ea465d5616883ae3c35ce5154c03b61a9d4a38a88a49415940e37ae0ceaaedcdcccfca399b9476d8258365a7bb08310be74b5a39bc7a7cb12429454720f74f28fc17d623b266742d8ddaafa72047c7750cc98b2c6c4c961a4" + "input_hex": "505443455850303100000000000040008000000000000099fa2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1b766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfef94177bb023d9d7c5100833a3118c84c3622cffdfcf9ef269ab3301b3f743188f72cdcdec411e43f9e3b6ff4726081181a1862d419cec048c04a6740f6bc3dc40971f31f3d75eb2876381215eae53ba1bc3df6ff2732974b93e28dd4b3aebfefc9ef64b302a500b3ba6d9d2b1e80bd41e9e01083165b0ce61ae0568bfdd0636e8bb172c54541023f120fbd80918f5db8b1c22ba347f037bffc938708f0a186b1ff66d42027050f609b06bc367c43ab77b92b476c06dbe812f32f902dc3aa0adbda55006565875cc089e30dba2a649e73a6c91fb015d1127ca004c6678f0760ab4b5a6a37666f1b5dd32a91892153769d4be8a8ee8125ea1f840ebb2dd4ca01a9be433b1c5cf441f704f02e98e2ef48daa2353cb7c7c990b451d06a99eefe79231d13744f9ca044c7212de9e4d78af50805b48c2b6afac6ec497bc9d2f3204f4f2e9852d6ec0c4ecc82728eb02858ea3ec55e60b18c723b9407de281724bfa1760b7a93bf4cb067a4f2eaf16d7fe5b39be6d560b98bf8493bfcdc226eb3ce698af1d8fa7d32166b0627652086c7e01ae8f599341888cd4825ea9fde682d4f74d8fe9aa6b9d2a845eeacb30cde228bf8f018cd6d427c3b28f5748b046efd689542d9a4f6c84130458dbabc9df9fcf2b359ce5426062b1e7efbc37335e2b498163e0ba20fcacbcbd33bfbb25439bbc715f5df6b4643d47fdf479817f5d392cc3f39717ef3ef1fe2110ed5d7988782770ef08bad7625ae4208cfd3f5cd6cc04ba19ef48a7a11795b983ce0264a20ec850e11ff63a6cc589e09f7edf46c18fd72c545661ef9eac54028d9849bec0d88942636b37632ec22e70ea5aaa9566fc489a88a4b4c15138550018178e8900db4b7cff378db2fa43db0b8e9c405586736c26ad9566d36b12431474c383d191342a226182735a225ef7aea8e6f2b0d22d2ba92c6fe645341cdfb29b1d0961d073c1ac6233bdfb53ef8352c5d7285456046f2bac77ebac039c4f5dc6225815401291ea80e5250c361b156e78c570339f5048f62c5b69ec4d797d2114c02cf0123cb62ec92f480271587f0c64d98ef0e0ab0aa2d9c713bf7b74ab2e04d8a662921ff809c9b15b4f2a190462d7b4dda99fda98511ec260d1f22a2a69ef2406d06a0fc207c793ce9a479cc19335c746d48aa29022face0af22f158e2c4369d5a9747b43d12954bb2b82e8e042178b3c24c0b494a634c4b71d106693870a84fbaac03e40591582295bb94e256d7e075da00e78a4d2ff2b3dd9c6eb125c4493612f2c74070dd0487329cfb81244ab9bcb25646e40c3229f8e73a55429c8678d1b872ed674aac356c124d734f66e1cace3e5ecf8235a7734ba37c82c173ea43a2f92bc004e471a343be6fe7f404fb5a821365f32781bfb85347d25fd441d15e577d20901f53704195e8affc03368c0b3bd60aabe9ef14e854db1c9e6cd738d19ef1d20d506f7395fd096de963fee0ab7153be49322416426318aade21f20c02d7656d7bdc9aa54c7bc65e01ae7e0a92046c37385cf24bef7b860f2bfc18f28af6e0fb55c85f1c145a2aa1367daf479c326ff49b4e5f3e658057fe42af469aa29485c8a00cfc12da2d901058ffd2ed50915e76caa917b0e67e17fc5f29157694c30d798898af7f95a0ba07d91947c27199818283e8ce3a0adde1a4432c871e5322d863afd96b2159eb014ef98c6db40d6504355584d8fc47e249039cfffe1d1265ec037820a7a50613d4276caee2974e2e7b3d1651aea7855b0dbdd5555c29ca95f0bf919e6a5b0505148c5695193153aec27bdb02ce8da800a94ab0050fdebbc4c4ff50a169b9f262fca3cef3158bc844a9850ceed6ccee5f671bce70855a8efcee9a9543636c96cbbe6bd62a8b3a50259e8bf76118439f1eedc7d4e6c4aec08fcdd00184282b3d14624c9e0bf8f9c8b9b419bbd4bf3e02025f65b0828c94c4e2d77ca2e4f2781adac547b8fb4d9c75803ab55d72241945473b17dc819b4065f76469008d39825ebe36b9723014f72e528b9c1a083ddaba882a82decc88ff7c98c56e11ab722c79498ea1ce8ee53f367c23645eb78d0df19e6eb1803c8789455c72b2f2b087dc6b96c163f3cc17aec950b38ad776897c4af84afeadae1f0f07c5fc57d6fe7f0498e97c5843aab30da8d9bd8591d52252efd3ad13447cd210fed5985165714d871771a338e4cdf3afd9f8aab2abb7abb54b3096fed9ba818898fefd88db1973808a2c9aacda2515382375738cd3198624e45695fc3f9eb7b773c6373b8cd6cb749ea40bfa42911bdc366cf807a226a6d6020c3a4e838c6b0e1a30cff1c4215533430533d900c7aec49a172647246b02ca14fffdd9758a588423ea8a0c543b77edffb2147aa1801c4303abd780b21a3767341c78cd6ae526dd33a2bcce1c0cce433f08940df280da20a226da5e1f9dfc785b921e6e4c122f87d173999bf72c7a2b1afb925147dc823e9f0db2f3f234ee67d753d8ff2fbc54844fa1db423a45bda9b800680969902026de586b9684ecf6f117a38de98f2ccb4fc30028ab0464d9b781c95b10733f7c93ad61b0f848e8041ee4af5dc0724080836c65d015ba81777aa0ba484cb05e54ccb78bc32a8663a5379672e8ec9bd85d766b482ac661942a2f129f756f806577a09982fb15a39c75230ea1813f2b0f1e7c886100575521a230adbb3d466c020c8c943d7af51df009833d4cae36b2dee8fee49ce5bb892063a98aa9db5aadfc2014abcd69860c851a51afce672fb80061b7f8ead67b038c4c416f951dc1428b6c0af3d8ab2badfb516f5dcc95cc0a761c76e51474e6da2af93764cab60971f8e77121d23ce64044ab15cad3002694eb6f59f0fd8e79c2b22e80f16ecf6c3918f355343bbba47139ae4719199ce80c83e62c785c5dfa99dff8b275e21778dd541aa548dd89dba7dc34977def3efb8c1a971c141644f8bf41fff8900bac68aabbafd854c33fe856f2d4c218b785940243bf1c6a8b0562387f8745378df8ed45486a2d764555fc8491acca624db15235da92bc88978f521bc636674c26002780d8e1e9674a0ca3c8a9d2774e0d47803392ab6b1135d3a9794473143c41c397fadbe99933c03d9cd76fbd06f86846cf8ff8ac1decbdcfe0ba9c382f0dc5332ca713d05aeab76c5711ed7d2d44c2ad4a247d033d87ae1b285854ecd90d677f0e81ea1324ea940e25bc6a30ecbd4468bda32df9f069431d6cf9b564c8603a1de0f916acba3c720c9145351de49897572affb7b33800a4e52504a614defc8fd5f63f232d4d5e5b03af29b03ad5f44e3897118a90424d329f987ccad0b566542fdd99eaeb2534f160c98e7f959b107dd71a1969995adc3f4bba526d05986d5663ca9f22390bf6c74b39b94f463dff0bd07f360101beb864f3e20227c5686edd2384753cca29c12dd8d9bf7626b79aef2481039d437679642867062feacd6d9ffba70a4913181f3ecb6f769c3b992ab1333f1311a9a99d659fc728673f31f6d875314e1e50413b3a28cbb41b39b43eb7d767aecdb59a73d8dca7442ab730ed6bfefb5111c554d10db07dc62e5e2023a4bbed6b5151fe48756c87337f7abcb318638a7164f2faae0d36b6944d4bb7c5377f1f857166d847184f6fa2d5fcaabc34970cf60884ddf647d9b4dac0de24cf36bdc22b230ff40069b595455e8b8f5a02466748cc1970064051843b087b1ecb7f64bd65cb2b5ab651527e8ae855928b83c7489bec8866c22002b4c3bab1a639392e0f4a45e82b6fb0cea9851edbf593dfbdd9e88effb058d8c05399193b56fb5de3b2f5a1d4c5725a2b2e28151d498cd4cd581fb3a50c9e747634fcca07570ce2b3e556d5007798ed5416d2a02a886ab2dad2fc337e09bab70a8692318530755dade307f509b8da79c63f599b385b172bfd6d5fc24f212d41e7b7b8a8df206f9238126d4729f69bfb3446794b2ee0666779818f00e1dcfd661a85aec1a0045b03758c6471c3a19c6fc85c1fadc9a436b8ba4df1f4120b6f7bd2b9f931cfeb2ab205e067ad2cce4e3ca2b8e16c2d260f77c205fb6364bea4ab810378c653d559b08e88278fc358e6798edd7ba81d4941e0185cbe0072fe7a70cc89fa573879b40220576003f3fe6beb7b8c4f763ced79a510299e5e5175b6d52b1d51f7fe328856f6fc0a33d34c45452b23939e69dde949ba59f13d55167bf06a1bc9d9c87eeac818a6e7c99463a6a60e6da6de43ff5ed82d8e485561fd60960172bbfc3d1602f6860157ef04614052557177f6430aa2cfc7bb8ea143a1244cd21db9e906522147cbfd2a141e566c05d3cc6fdb20357efc8618c1e53a2896d2cd7335165380f54419f714278301fb16b0e50288e77ed887aa04aad0a2459618f8e2605590d012051a79c020b95a1ef025587299b34eee3228705cd03a1fef63303e3939ecb174dcb0ccfc938123dcc4d4b516f70cca86b61a0e6a3052eae4023c34e46a166b6e64a18ba816280883bf47160ce9734cb6983e428d66075350ce276045d41" }, { "category": "body_tampering", "entrypoint": "bundle", "expected_failure": "aead_authentication", "id": "bundle-tampered-tag", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1b766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfef94177bb023d9d7c5100833a3118c84c3622cffdfcf9ef269ab3301b3f743188f72cdcdec411e43f9e3b6ff4726081181a1862d419cec048c04a6740f6bc3dc40971f31f3d75eb2876381215eae53ba1bc3df6ff2732974b93e28dd4b3aebfefc9ef64b302a500b3ba6d9d2b1e80bd41e9e01083165b0ce61ae0568bfdd0636e8bb172c54541023f120fbd80918f5db8b1c22ba347f037bffc938708f0a186b1ff66d42027050f609b06bc367c43ab77b92b476c06dbe812f32f902dc3aa0adbda55006565875cc089e30dba2a649e73a6c91fb015d1127ca004c6678f0760ab4b5a6a37666f1b5dd32a91892153769d4be8a8ee8125ea1f840ebb2dd4ca01a9be433b1c5cf441f704f02e98e2ef48daa2353cb7c7c990b451d06a99eefe79231d13744f9ca044c7212de9e4d78af50805b48c2b6afac6ec497bc9d2f3204f4f2e9852d6ec0c4ecc82728eb02858ea3ec55e60b18c723b9407de281724bfa1760b7a93bf4cb067a4f2eaf16d7fe5b39be6d560b98bf8493bfcdc226eb3ce698af1d8fa7d32166b0627652086c7e01ae8f599341888cd4825ea9fde682d4f74d8fe9aa6b9d2a845eeacb30cde228bf8f018cd6d427c3b28f5748b046efd689542d9a4f6c84130458dbabc9df9fcf2b359ce5426062b1e7efbc37335e2b498163e0ba20fcacbcbd33bfbb25439bbc715f5df6b4643d47fdf479817f5d392cc3f39717ef3ef1fe2110ed5d7988782770ef08bad7625ae4208cfd3f5cd6cc04ba19ef48a7a11795b983ce0264a20ec850e11ff63a6cc589e09f7edf46c18fd72c545661ef9eac54028d9849bec0d88942636b37632ec22e70ea5aaa9566fc489a88a4b4c15138550018178e8900db4b7cff378db2fa43db0b8e9c405586736c26ad9566d36b12431474c383d191342a226182735a225ef7aea8e6f2b0d22d2ba92c6fe645341cdfb29b1d0961d073c1ac6233bdfb53ef8352c5d7285456046f2bac77ebac039c4f5dc6225815401291ea80e5250c361b156e78c570339f5048f62c5b69ec4d797d2114c02cf0123cb62ec92f480271587f0c64d98ef0e0ab0aa2d9c713bf7b74ab2e04d8a662921ff809c9b15b4f2a190462d7b4dda99fda98511ec260d1f22a2a69ef2406d06a0fc207c793ce9a479cc19335c746d48aa29022face0af22f158e2c4369d5a9747b43d12954bb2b82e8e042178b3c24c0b494a634c4b71d106693870a84fbaac03e40591582295bb94e256d7e075da00e78a4d2ff2b3dd9c6eb125c4493612f2c74070dd0487329cfb81244ab9bcb25646e40c3229f8e73a55429c8678d1b872ed674aac356c124d734f66e1cace3e5ecf8235a7734ba37c82c173ea43a2f92bc004e471a343be6fe7f404fb5a821365f32781bfb85347d25fd441d15e577d20901f53704195e8affc03368c0b3bd60aabe9ef14e854db1c9e6cd738d19ef1d20d506f7395fd096de963fee0ab7153be49322416426318aade21f20c02d7656d7bdc9aa54c7bc65e01ae7e0a92046c37385cf24bef7b860f2bfc18f28af6e0fb55c85f1c145a2aa1367daf479c326ff49b4e5f3e658057fe42af469aa29485c8a00cfc12da2d901058ffd2ed50915e76caa917b0e67e17fc5f29157694c30d798898af7f95a0ba07d91947c27199818283e8ce3a0adde1a4432c871e5322d863afd96b2159eb014ef98c6db40d6504355584d8fc47e249039cfffe1d1265ec037820a7a50613d4276caee2974e2e7b3d1651aea7855b0dbdd5555c29ca95f0bf919e6a5b0505148c5695193153aec27bdb02ce8da800a94ab0050fdebbc4c4ff50a169b9f262fca3cef3158bc844a9850ceed6ccee5f671bce70855a8efcee9a9543636c96cbbe6bd62a8b3a50259e8bf76118439f1eedc7d4e6c4aec08fcdd00184282b3d14624c9e0bf8f9c8b9b419bbd4bf3e02025f65b0828c94c4e2d77ca2e4f2781adac547b8fb4d9c75803ab55d72241945473b17dc819b4065f76469008d39825ebe36b9723014f72e528b9c1a083ddaba882a82decc88ff7c98c56e11ab722c79498ea1ce8ee53f367c23645eb78d0df19e6eb1803c8789455c72b2f2b087dc6b96c163f3cc17aec950b38ad776897c4af84afeadae1f0f07c5fc57d6fe7f0498e97c5843aab30da8d9bd8591d52252efd3ad13447cd210fed5985165714d871771a338e4cdf3afd9f8aab2abb7abb54b3096fed9ba818898fefd88db1973808a2c9aacda2515382375738cd3198624e45695fc3f9eb7b773c6373b8cd6cb749ea40bfa42911bdc366cf807a226a6d6020c3a4e838c6b0e1a30cff1c4215533430533d900c7aec49a172647246b02ca14fffdd9758a588423ea8a0c543b77edffb2147aa1801c4303abd780b21a3767341c78cd6ae526dd33a2bcce1c0cce433f08940df280da20a226da5e1f9dfc785b921e6e4c122f87d173999bf72c7a2b1afb925147dc823e9f0db2f3f234ee67d753d8ff2fbc54844fa1db423a45bda9b800680969902026de586b9684ecf6f117a38de98f2ccb4fc30028ab0464d9b781c95b10733f7c93ad61b0f848e8041ee4af5dc0724080836c65d015ba81777aa0ba484cb05e54ccb78bc32a8663a5379672e8ec9bd85d766b482ac661942a2f129f756f806577a09982fb15a39c75230ea1813f2b0f1e7c886100575521a230adbb3d466c020c8c943d7af51df009833d4cae36b2dee8fee49ce5bb892063a98aa9db5aadfc2014abcd69860c851a51afce466e09d0a111a80a331b62bcbd90ce44cda1535e7d8ce1dd3f6aec2b759edc4a258ddb87cc17f50214063f2b8976450aa2c8f07ef7b13152cc0760359a441ab31136554b8fc901ad9e08b312bfb180de3f9cf90e93b5d15a6be4f5482a528d194ce9be31274d185dfd9a8acc9866146393780d55af30f9ede8aa16093477991f1eeb9dab270c64b586496f017c5b80fb5c796faa2e5964d33ec8b61785a2c8b4b725d2033cbeca9a76a1183fd7456628e96cc4787a2c53b04519e5257d4be45d7142b52a733c19a6fee2eb36f67129d315521499a03943bb8d25d869c2f7beecc71102e31b9be1f5c5c999a172647d6003833b5a6f89f3d1ecfc66cf0dd78959b60eaf79adecbc0d7ff6da7cdd4edd85b75d2306445a3ab6dee5d08c1d2ce4a2be5944a962bc03aecb29edc15de96c16cedb810af7d4ca841f940c7a404f7ff5b8fd208ee9009952d87d783464e8611afd05a8767cb90ec3dcd1c697bdf5eb54776aafb7e29d1125642524a7312bec6ab442eea2ab5d9e4ad2cf98108a24a4fe9847611cc5b758470bd93d1af444e7d23f1d883b8b94944191993edf45eb862d37ff1819d87b1c2b8a3bd47dc589bc36d26a2ec2190b97970a29af2fa6d89edb90fb4785142ab8b4f250b0e6a4086f7d4397665c1e4840a9d809bec3b306aa8e5530d69da39289443876b79ffabdce5d4a774acab008efcedaaa671dba891ab013dff640c9799e572e1768e49d91e7abb6110e4e50109e2ba94aa42b3891cba732061a1c341c43f8dcc6242a77f18c7beeac74e4d021454cf1ade2dfdfa61384bb8c0b51913f29657cd0139f9fbdc359424a65a5737c9e2d36d7f44d8b47e4474fbfd5919718f7e95e4fa3b5eaca5cd1f6dcb68cf559e3d3d964db726ce5ae56bc624b301c94d4083411458e8a3acfb37606397dcc70e6a4a1a42b19caaedb0fc77fd41b6bd91541a28e9b2d44130ae38749bb0c6d37a2f00186726af1259b993f7c8965a87b6fe16bb8049fbbb592fa48c90def4b61d95fc123d948ece30e489626b4e004e183dbac2f58554c9e3c3428596ff284cc8ab5f7b3dddb3736dc4212744624a0c7589c0526b3b03ce88a57bb02bcb70f8daf230a56938337f1143dac4367e61ad80e1847bb594b39ee829acd0c2e739a21cda51797a8b96e907fe29bd0dc97697538ebc4b6688e3f61e7172980afe0048d9db619b71f11e087f9a364ffa75183f19c3e6d4d9e2cb9f4379d4f5d1495a6daeefcd2c84931bf9bbce7f0f207496cbfbe28770c821f3c35ad6400808fd7c66fc4eba0a3cc03c7a25e47b8de9579553b45eabc1e09db7ef86362689f7da2352a6ec14cd9bb37387c253705a29444260a1ababa3c6f84885899a1b4189fee300136b44ffd3157ea465d5616883ae3c35ce5154c03b61a9d4a38a88a49415940e37ae0ceaaedcdcccfca399b9476d8258365a7bb08310be74b5a39bc7a7cb12429454720f74f28fc17d623b266742d8ddaafa72047c7750cc98b2c6c4c961a5" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1b766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfef94177bb023d9d7c5100833a3118c84c3622cffdfcf9ef269ab3301b3f743188f72cdcdec411e43f9e3b6ff4726081181a1862d419cec048c04a6740f6bc3dc40971f31f3d75eb2876381215eae53ba1bc3df6ff2732974b93e28dd4b3aebfefc9ef64b302a500b3ba6d9d2b1e80bd41e9e01083165b0ce61ae0568bfdd0636e8bb172c54541023f120fbd80918f5db8b1c22ba347f037bffc938708f0a186b1ff66d42027050f609b06bc367c43ab77b92b476c06dbe812f32f902dc3aa0adbda55006565875cc089e30dba2a649e73a6c91fb015d1127ca004c6678f0760ab4b5a6a37666f1b5dd32a91892153769d4be8a8ee8125ea1f840ebb2dd4ca01a9be433b1c5cf441f704f02e98e2ef48daa2353cb7c7c990b451d06a99eefe79231d13744f9ca044c7212de9e4d78af50805b48c2b6afac6ec497bc9d2f3204f4f2e9852d6ec0c4ecc82728eb02858ea3ec55e60b18c723b9407de281724bfa1760b7a93bf4cb067a4f2eaf16d7fe5b39be6d560b98bf8493bfcdc226eb3ce698af1d8fa7d32166b0627652086c7e01ae8f599341888cd4825ea9fde682d4f74d8fe9aa6b9d2a845eeacb30cde228bf8f018cd6d427c3b28f5748b046efd689542d9a4f6c84130458dbabc9df9fcf2b359ce5426062b1e7efbc37335e2b498163e0ba20fcacbcbd33bfbb25439bbc715f5df6b4643d47fdf479817f5d392cc3f39717ef3ef1fe2110ed5d7988782770ef08bad7625ae4208cfd3f5cd6cc04ba19ef48a7a11795b983ce0264a20ec850e11ff63a6cc589e09f7edf46c18fd72c545661ef9eac54028d9849bec0d88942636b37632ec22e70ea5aaa9566fc489a88a4b4c15138550018178e8900db4b7cff378db2fa43db0b8e9c405586736c26ad9566d36b12431474c383d191342a226182735a225ef7aea8e6f2b0d22d2ba92c6fe645341cdfb29b1d0961d073c1ac6233bdfb53ef8352c5d7285456046f2bac77ebac039c4f5dc6225815401291ea80e5250c361b156e78c570339f5048f62c5b69ec4d797d2114c02cf0123cb62ec92f480271587f0c64d98ef0e0ab0aa2d9c713bf7b74ab2e04d8a662921ff809c9b15b4f2a190462d7b4dda99fda98511ec260d1f22a2a69ef2406d06a0fc207c793ce9a479cc19335c746d48aa29022face0af22f158e2c4369d5a9747b43d12954bb2b82e8e042178b3c24c0b494a634c4b71d106693870a84fbaac03e40591582295bb94e256d7e075da00e78a4d2ff2b3dd9c6eb125c4493612f2c74070dd0487329cfb81244ab9bcb25646e40c3229f8e73a55429c8678d1b872ed674aac356c124d734f66e1cace3e5ecf8235a7734ba37c82c173ea43a2f92bc004e471a343be6fe7f404fb5a821365f32781bfb85347d25fd441d15e577d20901f53704195e8affc03368c0b3bd60aabe9ef14e854db1c9e6cd738d19ef1d20d506f7395fd096de963fee0ab7153be49322416426318aade21f20c02d7656d7bdc9aa54c7bc65e01ae7e0a92046c37385cf24bef7b860f2bfc18f28af6e0fb55c85f1c145a2aa1367daf479c326ff49b4e5f3e658057fe42af469aa29485c8a00cfc12da2d901058ffd2ed50915e76caa917b0e67e17fc5f29157694c30d798898af7f95a0ba07d91947c27199818283e8ce3a0adde1a4432c871e5322d863afd96b2159eb014ef98c6db40d6504355584d8fc47e249039cfffe1d1265ec037820a7a50613d4276caee2974e2e7b3d1651aea7855b0dbdd5555c29ca95f0bf919e6a5b0505148c5695193153aec27bdb02ce8da800a94ab0050fdebbc4c4ff50a169b9f262fca3cef3158bc844a9850ceed6ccee5f671bce70855a8efcee9a9543636c96cbbe6bd62a8b3a50259e8bf76118439f1eedc7d4e6c4aec08fcdd00184282b3d14624c9e0bf8f9c8b9b419bbd4bf3e02025f65b0828c94c4e2d77ca2e4f2781adac547b8fb4d9c75803ab55d72241945473b17dc819b4065f76469008d39825ebe36b9723014f72e528b9c1a083ddaba882a82decc88ff7c98c56e11ab722c79498ea1ce8ee53f367c23645eb78d0df19e6eb1803c8789455c72b2f2b087dc6b96c163f3cc17aec950b38ad776897c4af84afeadae1f0f07c5fc57d6fe7f0498e97c5843aab30da8d9bd8591d52252efd3ad13447cd210fed5985165714d871771a338e4cdf3afd9f8aab2abb7abb54b3096fed9ba818898fefd88db1973808a2c9aacda2515382375738cd3198624e45695fc3f9eb7b773c6373b8cd6cb749ea40bfa42911bdc366cf807a226a6d6020c3a4e838c6b0e1a30cff1c4215533430533d900c7aec49a172647246b02ca14fffdd9758a588423ea8a0c543b77edffb2147aa1801c4303abd780b21a3767341c78cd6ae526dd33a2bcce1c0cce433f08940df280da20a226da5e1f9dfc785b921e6e4c122f87d173999bf72c7a2b1afb925147dc823e9f0db2f3f234ee67d753d8ff2fbc54844fa1db423a45bda9b800680969902026de586b9684ecf6f117a38de98f2ccb4fc30028ab0464d9b781c95b10733f7c93ad61b0f848e8041ee4af5dc0724080836c65d015ba81777aa0ba484cb05e54ccb78bc32a8663a5379672e8ec9bd85d766b482ac661942a2f129f756f806577a09982fb15a39c75230ea1813f2b0f1e7c886100575521a230adbb3d466c020c8c943d7af51df009833d4cae36b2dee8fee49ce5bb892063a98aa9db5aadfc2014abcd69860c851a51afce672fb80061b7f8ead67b038c4c416f951dc1428b6c0af3d8ab2badfb516f5dcc95cc0a761c76e51474e6da2af93764cab60971f8e77121d23ce64044ab15cad3002694eb6f59f0fd8e79c2b22e80f16ecf6c3918f355343bbba47139ae4719199ce80c83e62c785c5dfa99dff8b275e21778dd541aa548dd89dba7dc34977def3efb8c1a971c141644f8bf41fff8900bac68aabbafd854c33fe856f2d4c218b785940243bf1c6a8b0562387f8745378df8ed45486a2d764555fc8491acca624db15235da92bc88978f521bc636674c26002780d8e1e9674a0ca3c8a9d2774e0d47803392ab6b1135d3a9794473143c41c397fadbe99933c03d9cd76fbd06f86846cf8ff8ac1decbdcfe0ba9c382f0dc5332ca713d05aeab76c5711ed7d2d44c2ad4a247d033d87ae1b285854ecd90d677f0e81ea1324ea940e25bc6a30ecbd4468bda32df9f069431d6cf9b564c8603a1de0f916acba3c720c9145351de49897572affb7b33800a4e52504a614defc8fd5f63f232d4d5e5b03af29b03ad5f44e3897118a90424d329f987ccad0b566542fdd99eaeb2534f160c98e7f959b107dd71a1969995adc3f4bba526d05986d5663ca9f22390bf6c74b39b94f463dff0bd07f360101beb864f3e20227c5686edd2384753cca29c12dd8d9bf7626b79aef2481039d437679642867062feacd6d9ffba70a4913181f3ecb6f769c3b992ab1333f1311a9a99d659fc728673f31f6d875314e1e50413b3a28cbb41b39b43eb7d767aecdb59a73d8dca7442ab730ed6bfefb5111c554d10db07dc62e5e2023a4bbed6b5151fe48756c87337f7abcb318638a7164f2faae0d36b6944d4bb7c5377f1f857166d847184f6fa2d5fcaabc34970cf60884ddf647d9b4dac0de24cf36bdc22b230ff40069b595455e8b8f5a02466748cc1970064051843b087b1ecb7f64bd65cb2b5ab651527e8ae855928b83c7489bec8866c22002b4c3bab1a639392e0f4a45e82b6fb0cea9851edbf593dfbdd9e88effb058d8c05399193b56fb5de3b2f5a1d4c5725a2b2e28151d498cd4cd581fb3a50c9e747634fcca07570ce2b3e556d5007798ed5416d2a02a886ab2dad2fc337e09bab70a8692318530755dade307f509b8da79c63f599b385b172bfd6d5fc24f212d41e7b7b8a8df206f9238126d4729f69bfb3446794b2ee0666779818f00e1dcfd661a85aec1a0045b03758c6471c3a19c6fc85c1fadc9a436b8ba4df1f4120b6f7bd2b9f931cfeb2ab205e067ad2cce4e3ca2b8e16c2d260f77c205fb6364bea4ab810378c653d559b08e88278fc358e6798edd7ba81d4941e0185cbe0072fe7a70cc89fa573879b40220576003f3fe6beb7b8c4f763ced79a510299e5e5175b6d52b1d51f7fe328856f6fc0a33d34c45452b23939e69dde949ba59f13d55167bf06a1bc9d9c87eeac818a6e7c99463a6a60e6da6de43ff5ed82d8e485561fd60960172bbfc3d1602f6860157ef04614052557177f6430aa2cfc7bb8ea143a1244cd21db9e906522147cbfd2a141e566c05d3cc6fdb20357efc8618c1e53a2896d2cd7335165380f54419f714278301fb16b0e50288e77ed887aa04aad0a2459618f8e2605590d012051a79c020b95a1ef025587299b34eee3228705cd03a1fef63303e3939ecb174dcb0ccfc938123dcc4d4b516f70cca86b61a0e6a3052eae4023c34e46a166b6e64a18ba816280883bf47160ce9734cb6983e428d66075350ce276045d40" }, { "category": "malformed_length", "entrypoint": "bundle", "expected_failure": "truncated", "id": "bundle-truncated", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1b766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfef94177bb023d9d7c5100833a3118c84c3622cffdfcf9ef269ab3301b3f743188f72cdcdec411e43f9e3b6ff4726081181a1862d419cec048c04a6740f6bc3dc40971f31f3d75eb2876381215eae53ba1bc3df6ff2732974b93e28dd4b3aebfefc9ef64b302a500b3ba6d9d2b1e80bd41e9e01083165b0ce61ae0568bfdd0636e8bb172c54541023f120fbd80918f5db8b1c22ba347f037bffc938708f0a186b1ff66d42027050f609b06bc367c43ab77b92b476c06dbe812f32f902dc3aa0adbda55006565875cc089e30dba2a649e73a6c91fb015d1127ca004c6678f0760ab4b5a6a37666f1b5dd32a91892153769d4be8a8ee8125ea1f840ebb2dd4ca01a9be433b1c5cf441f704f02e98e2ef48daa2353cb7c7c990b451d06a99eefe79231d13744f9ca044c7212de9e4d78af50805b48c2b6afac6ec497bc9d2f3204f4f2e9852d6ec0c4ecc82728eb02858ea3ec55e60b18c723b9407de281724bfa1760b7a93bf4cb067a4f2eaf16d7fe5b39be6d560b98bf8493bfcdc226eb3ce698af1d8fa7d32166b0627652086c7e01ae8f599341888cd4825ea9fde682d4f74d8fe9aa6b9d2a845eeacb30cde228bf8f018cd6d427c3b28f5748b046efd689542d9a4f6c84130458dbabc9df9fcf2b359ce5426062b1e7efbc37335e2b498163e0ba20fcacbcbd33bfbb25439bbc715f5df6b4643d47fdf479817f5d392cc3f39717ef3ef1fe2110ed5d7988782770ef08bad7625ae4208cfd3f5cd6cc04ba19ef48a7a11795b983ce0264a20ec850e11ff63a6cc589e09f7edf46c18fd72c545661ef9eac54028d9849bec0d88942636b37632ec22e70ea5aaa9566fc489a88a4b4c15138550018178e8900db4b7cff378db2fa43db0b8e9c405586736c26ad9566d36b12431474c383d191342a226182735a225ef7aea8e6f2b0d22d2ba92c6fe645341cdfb29b1d0961d073c1ac6233bdfb53ef8352c5d7285456046f2bac77ebac039c4f5dc6225815401291ea80e5250c361b156e78c570339f5048f62c5b69ec4d797d2114c02cf0123cb62ec92f480271587f0c64d98ef0e0ab0aa2d9c713bf7b74ab2e04d8a662921ff809c9b15b4f2a190462d7b4dda99fda98511ec260d1f22a2a69ef2406d06a0fc207c793ce9a479cc19335c746d48aa29022face0af22f158e2c4369d5a9747b43d12954bb2b82e8e042178b3c24c0b494a634c4b71d106693870a84fbaac03e40591582295bb94e256d7e075da00e78a4d2ff2b3dd9c6eb125c4493612f2c74070dd0487329cfb81244ab9bcb25646e40c3229f8e73a55429c8678d1b872ed674aac356c124d734f66e1cace3e5ecf8235a7734ba37c82c173ea43a2f92bc004e471a343be6fe7f404fb5a821365f32781bfb85347d25fd441d15e577d20901f53704195e8affc03368c0b3bd60aabe9ef14e854db1c9e6cd738d19ef1d20d506f7395fd096de963fee0ab7153be49322416426318aade21f20c02d7656d7bdc9aa54c7bc65e01ae7e0a92046c37385cf24bef7b860f2bfc18f28af6e0fb55c85f1c145a2aa1367daf479c326ff49b4e5f3e658057fe42af469aa29485c8a00cfc12da2d901058ffd2ed50915e76caa917b0e67e17fc5f29157694c30d798898af7f95a0ba07d91947c27199818283e8ce3a0adde1a4432c871e5322d863afd96b2159eb014ef98c6db40d6504355584d8fc47e249039cfffe1d1265ec037820a7a50613d4276caee2974e2e7b3d1651aea7855b0dbdd5555c29ca95f0bf919e6a5b0505148c5695193153aec27bdb02ce8da800a94ab0050fdebbc4c4ff50a169b9f262fca3cef3158bc844a9850ceed6ccee5f671bce70855a8efcee9a9543636c96cbbe6bd62a8b3a50259e8bf76118439f1eedc7d4e6c4aec08fcdd00184282b3d14624c9e0bf8f9c8b9b419bbd4bf3e02025f65b0828c94c4e2d77ca2e4f2781adac547b8fb4d9c75803ab55d72241945473b17dc819b4065f76469008d39825ebe36b9723014f72e528b9c1a083ddaba882a82decc88ff7c98c56e11ab722c79498ea1ce8ee53f367c23645eb78d0df19e6eb1803c8789455c72b2f2b087dc6b96c163f3cc17aec950b38ad776897c4af84afeadae1f0f07c5fc57d6fe7f0498e97c5843aab30da8d9bd8591d52252efd3ad13447cd210fed5985165714d871771a338e4cdf3afd9f8aab2abb7abb54b3096fed9ba818898fefd88db1973808a2c9aacda2515382375738cd3198624e45695fc3f9eb7b773c6373b8cd6cb749ea40bfa42911bdc366cf807a226a6d6020c3a4e838c6b0e1a30cff1c4215533430533d900c7aec49a172647246b02ca14fffdd9758a588423ea8a0c543b77edffb2147aa1801c4303abd780b21a3767341c78cd6ae526dd33a2bcce1c0cce433f08940df280da20a226da5e1f9dfc785b921e6e4c122f87d173999bf72c7a2b1afb925147dc823e9f0db2f3f234ee67d753d8ff2fbc54844fa1db423a45bda9b800680969902026de586b9684ecf6f117a38de98f2ccb4fc30028ab0464d9b781c95b10733f7c93ad61b0f848e8041ee4af5dc0724080836c65d015ba81777aa0ba484cb05e54ccb78bc32a8663a5379672e8ec9bd85d766b482ac661942a2f129f756f806577a09982fb15a39c75230ea1813f2b0f1e7c886100575521a230adbb3d466c020c8c943d7af51df009833d4cae36b2dee8fee49ce5bb892063a98aa9db5aadfc2014abcd69860c851a51afce466e09d0a111a80a331b62bcbd90ce44cda1535e7d8ce1dd3f6aec2b759edc4a258ddb87cc17f50214063f2b8976450aa2c8f07ef7b13152cc0760359a441ab31136554b8fc901ad9e08b312bfb180de3f9cf90e93b5d15a6be4f5482a528d194ce9be31274d185dfd9a8acc9866146393780d55af30f9ede8aa16093477991f1eeb9dab270c64b586496f017c5b80fb5c796faa2e5964d33ec8b61785a2c8b4b725d2033cbeca9a76a1183fd7456628e96cc4787a2c53b04519e5257d4be45d7142b52a733c19a6fee2eb36f67129d315521499a03943bb8d25d869c2f7beecc71102e31b9be1f5c5c999a172647d6003833b5a6f89f3d1ecfc66cf0dd78959b60eaf79adecbc0d7ff6da7cdd4edd85b75d2306445a3ab6dee5d08c1d2ce4a2be5944a962bc03aecb29edc15de96c16cedb810af7d4ca841f940c7a404f7ff5b8fd208ee9009952d87d783464e8611afd05a8767cb90ec3dcd1c697bdf5eb54776aafb7e29d1125642524a7312bec6ab442eea2ab5d9e4ad2cf98108a24a4fe9847611cc5b758470bd93d1af444e7d23f1d883b8b94944191993edf45eb862d37ff1819d87b1c2b8a3bd47dc589bc36d26a2ec2190b97970a29af2fa6d89edb90fb4785142ab8b4f250b0e6a4086f7d4397665c1e4840a9d809bec3b306aa8e5530d69da39289443876b79ffabdce5d4a774acab008efcedaaa671dba891ab013dff640c9799e572e1768e49d91e7abb6110e4e50109e2ba94aa42b3891cba732061a1c341c43f8dcc6242a77f18c7beeac74e4d021454cf1ade2dfdfa61384bb8c0b51913f29657cd0139f9fbdc359424a65a5737c9e2d36d7f44d8b47e4474fbfd5919718f7e95e4fa3b5eaca5cd1f6dcb68cf559e3d3d964db726ce5ae56bc624b301c94d4083411458e8a3acfb37606397dcc70e6a4a1a42b19caaedb0fc77fd41b6bd91541a28e9b2d44130ae38749bb0c6d37a2f00186726af1259b993f7c8965a87b6fe16bb8049fbbb592fa48c90def4b61d95fc123d948ece30e489626b4e004e183dbac2f58554c9e3c3428596ff284cc8ab5f7b3dddb3736dc4212744624a0c7589c0526b3b03ce88a57bb02bcb70f8daf230a56938337f1143dac4367e61ad80e1847bb594b39ee829acd0c2e739a21cda51797a8b96e907fe29bd0dc97697538ebc4b6688e3f61e7172980afe0048d9db619b71f11e087f9a364ffa75183f19c3e6d4d9e2cb9f4379d4f5d1495a6daeefcd2c84931bf9bbce7f0f207496cbfbe28770c821f3c35ad6400808fd7c66fc4eba0a3cc03c7a25e47b8de9579553b45eabc1e09db7ef86362689f7da2352a6ec14cd9bb37387c253705a29444260a1ababa3c6f84885899a1b4189fee300136b44ffd3157ea465d5616883ae3c35ce5154c03b61a9d4a38a88a49415940e37ae0ceaaedcdcccfca399b9476d8258365a7bb08310be74b5a39bc7a7cb12429454720f74f28fc17d623b266742d8ddaafa72047c7750cc98b2c6c4c961" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1b766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfef94177bb023d9d7c5100833a3118c84c3622cffdfcf9ef269ab3301b3f743188f72cdcdec411e43f9e3b6ff4726081181a1862d419cec048c04a6740f6bc3dc40971f31f3d75eb2876381215eae53ba1bc3df6ff2732974b93e28dd4b3aebfefc9ef64b302a500b3ba6d9d2b1e80bd41e9e01083165b0ce61ae0568bfdd0636e8bb172c54541023f120fbd80918f5db8b1c22ba347f037bffc938708f0a186b1ff66d42027050f609b06bc367c43ab77b92b476c06dbe812f32f902dc3aa0adbda55006565875cc089e30dba2a649e73a6c91fb015d1127ca004c6678f0760ab4b5a6a37666f1b5dd32a91892153769d4be8a8ee8125ea1f840ebb2dd4ca01a9be433b1c5cf441f704f02e98e2ef48daa2353cb7c7c990b451d06a99eefe79231d13744f9ca044c7212de9e4d78af50805b48c2b6afac6ec497bc9d2f3204f4f2e9852d6ec0c4ecc82728eb02858ea3ec55e60b18c723b9407de281724bfa1760b7a93bf4cb067a4f2eaf16d7fe5b39be6d560b98bf8493bfcdc226eb3ce698af1d8fa7d32166b0627652086c7e01ae8f599341888cd4825ea9fde682d4f74d8fe9aa6b9d2a845eeacb30cde228bf8f018cd6d427c3b28f5748b046efd689542d9a4f6c84130458dbabc9df9fcf2b359ce5426062b1e7efbc37335e2b498163e0ba20fcacbcbd33bfbb25439bbc715f5df6b4643d47fdf479817f5d392cc3f39717ef3ef1fe2110ed5d7988782770ef08bad7625ae4208cfd3f5cd6cc04ba19ef48a7a11795b983ce0264a20ec850e11ff63a6cc589e09f7edf46c18fd72c545661ef9eac54028d9849bec0d88942636b37632ec22e70ea5aaa9566fc489a88a4b4c15138550018178e8900db4b7cff378db2fa43db0b8e9c405586736c26ad9566d36b12431474c383d191342a226182735a225ef7aea8e6f2b0d22d2ba92c6fe645341cdfb29b1d0961d073c1ac6233bdfb53ef8352c5d7285456046f2bac77ebac039c4f5dc6225815401291ea80e5250c361b156e78c570339f5048f62c5b69ec4d797d2114c02cf0123cb62ec92f480271587f0c64d98ef0e0ab0aa2d9c713bf7b74ab2e04d8a662921ff809c9b15b4f2a190462d7b4dda99fda98511ec260d1f22a2a69ef2406d06a0fc207c793ce9a479cc19335c746d48aa29022face0af22f158e2c4369d5a9747b43d12954bb2b82e8e042178b3c24c0b494a634c4b71d106693870a84fbaac03e40591582295bb94e256d7e075da00e78a4d2ff2b3dd9c6eb125c4493612f2c74070dd0487329cfb81244ab9bcb25646e40c3229f8e73a55429c8678d1b872ed674aac356c124d734f66e1cace3e5ecf8235a7734ba37c82c173ea43a2f92bc004e471a343be6fe7f404fb5a821365f32781bfb85347d25fd441d15e577d20901f53704195e8affc03368c0b3bd60aabe9ef14e854db1c9e6cd738d19ef1d20d506f7395fd096de963fee0ab7153be49322416426318aade21f20c02d7656d7bdc9aa54c7bc65e01ae7e0a92046c37385cf24bef7b860f2bfc18f28af6e0fb55c85f1c145a2aa1367daf479c326ff49b4e5f3e658057fe42af469aa29485c8a00cfc12da2d901058ffd2ed50915e76caa917b0e67e17fc5f29157694c30d798898af7f95a0ba07d91947c27199818283e8ce3a0adde1a4432c871e5322d863afd96b2159eb014ef98c6db40d6504355584d8fc47e249039cfffe1d1265ec037820a7a50613d4276caee2974e2e7b3d1651aea7855b0dbdd5555c29ca95f0bf919e6a5b0505148c5695193153aec27bdb02ce8da800a94ab0050fdebbc4c4ff50a169b9f262fca3cef3158bc844a9850ceed6ccee5f671bce70855a8efcee9a9543636c96cbbe6bd62a8b3a50259e8bf76118439f1eedc7d4e6c4aec08fcdd00184282b3d14624c9e0bf8f9c8b9b419bbd4bf3e02025f65b0828c94c4e2d77ca2e4f2781adac547b8fb4d9c75803ab55d72241945473b17dc819b4065f76469008d39825ebe36b9723014f72e528b9c1a083ddaba882a82decc88ff7c98c56e11ab722c79498ea1ce8ee53f367c23645eb78d0df19e6eb1803c8789455c72b2f2b087dc6b96c163f3cc17aec950b38ad776897c4af84afeadae1f0f07c5fc57d6fe7f0498e97c5843aab30da8d9bd8591d52252efd3ad13447cd210fed5985165714d871771a338e4cdf3afd9f8aab2abb7abb54b3096fed9ba818898fefd88db1973808a2c9aacda2515382375738cd3198624e45695fc3f9eb7b773c6373b8cd6cb749ea40bfa42911bdc366cf807a226a6d6020c3a4e838c6b0e1a30cff1c4215533430533d900c7aec49a172647246b02ca14fffdd9758a588423ea8a0c543b77edffb2147aa1801c4303abd780b21a3767341c78cd6ae526dd33a2bcce1c0cce433f08940df280da20a226da5e1f9dfc785b921e6e4c122f87d173999bf72c7a2b1afb925147dc823e9f0db2f3f234ee67d753d8ff2fbc54844fa1db423a45bda9b800680969902026de586b9684ecf6f117a38de98f2ccb4fc30028ab0464d9b781c95b10733f7c93ad61b0f848e8041ee4af5dc0724080836c65d015ba81777aa0ba484cb05e54ccb78bc32a8663a5379672e8ec9bd85d766b482ac661942a2f129f756f806577a09982fb15a39c75230ea1813f2b0f1e7c886100575521a230adbb3d466c020c8c943d7af51df009833d4cae36b2dee8fee49ce5bb892063a98aa9db5aadfc2014abcd69860c851a51afce672fb80061b7f8ead67b038c4c416f951dc1428b6c0af3d8ab2badfb516f5dcc95cc0a761c76e51474e6da2af93764cab60971f8e77121d23ce64044ab15cad3002694eb6f59f0fd8e79c2b22e80f16ecf6c3918f355343bbba47139ae4719199ce80c83e62c785c5dfa99dff8b275e21778dd541aa548dd89dba7dc34977def3efb8c1a971c141644f8bf41fff8900bac68aabbafd854c33fe856f2d4c218b785940243bf1c6a8b0562387f8745378df8ed45486a2d764555fc8491acca624db15235da92bc88978f521bc636674c26002780d8e1e9674a0ca3c8a9d2774e0d47803392ab6b1135d3a9794473143c41c397fadbe99933c03d9cd76fbd06f86846cf8ff8ac1decbdcfe0ba9c382f0dc5332ca713d05aeab76c5711ed7d2d44c2ad4a247d033d87ae1b285854ecd90d677f0e81ea1324ea940e25bc6a30ecbd4468bda32df9f069431d6cf9b564c8603a1de0f916acba3c720c9145351de49897572affb7b33800a4e52504a614defc8fd5f63f232d4d5e5b03af29b03ad5f44e3897118a90424d329f987ccad0b566542fdd99eaeb2534f160c98e7f959b107dd71a1969995adc3f4bba526d05986d5663ca9f22390bf6c74b39b94f463dff0bd07f360101beb864f3e20227c5686edd2384753cca29c12dd8d9bf7626b79aef2481039d437679642867062feacd6d9ffba70a4913181f3ecb6f769c3b992ab1333f1311a9a99d659fc728673f31f6d875314e1e50413b3a28cbb41b39b43eb7d767aecdb59a73d8dca7442ab730ed6bfefb5111c554d10db07dc62e5e2023a4bbed6b5151fe48756c87337f7abcb318638a7164f2faae0d36b6944d4bb7c5377f1f857166d847184f6fa2d5fcaabc34970cf60884ddf647d9b4dac0de24cf36bdc22b230ff40069b595455e8b8f5a02466748cc1970064051843b087b1ecb7f64bd65cb2b5ab651527e8ae855928b83c7489bec8866c22002b4c3bab1a639392e0f4a45e82b6fb0cea9851edbf593dfbdd9e88effb058d8c05399193b56fb5de3b2f5a1d4c5725a2b2e28151d498cd4cd581fb3a50c9e747634fcca07570ce2b3e556d5007798ed5416d2a02a886ab2dad2fc337e09bab70a8692318530755dade307f509b8da79c63f599b385b172bfd6d5fc24f212d41e7b7b8a8df206f9238126d4729f69bfb3446794b2ee0666779818f00e1dcfd661a85aec1a0045b03758c6471c3a19c6fc85c1fadc9a436b8ba4df1f4120b6f7bd2b9f931cfeb2ab205e067ad2cce4e3ca2b8e16c2d260f77c205fb6364bea4ab810378c653d559b08e88278fc358e6798edd7ba81d4941e0185cbe0072fe7a70cc89fa573879b40220576003f3fe6beb7b8c4f763ced79a510299e5e5175b6d52b1d51f7fe328856f6fc0a33d34c45452b23939e69dde949ba59f13d55167bf06a1bc9d9c87eeac818a6e7c99463a6a60e6da6de43ff5ed82d8e485561fd60960172bbfc3d1602f6860157ef04614052557177f6430aa2cfc7bb8ea143a1244cd21db9e906522147cbfd2a141e566c05d3cc6fdb20357efc8618c1e53a2896d2cd7335165380f54419f714278301fb16b0e50288e77ed887aa04aad0a2459618f8e2605590d012051a79c020b95a1ef025587299b34eee3228705cd03a1fef63303e3939ecb174dcb0ccfc938123dcc4d4b516f70cca86b61a0e6a3052eae4023c34e46a166b6e64a18ba816280883bf47160ce9734cb6983e428d66075350ce276045d" }, { "category": "malformed_length", "entrypoint": "bundle", "expected_failure": "zero_key_length", "id": "bundle-zero-key-length", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c70000101112131415161718191a1b766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfef94177bb023d9d7c5100833a3118c84c3622cffdfcf9ef269ab3301b3f743188f72cdcdec411e43f9e3b6ff4726081181a1862d419cec048c04a6740f6bc3dc40971f31f3d75eb2876381215eae53ba1bc3df6ff2732974b93e28dd4b3aebfefc9ef64b302a500b3ba6d9d2b1e80bd41e9e01083165b0ce61ae0568bfdd0636e8bb172c54541023f120fbd80918f5db8b1c22ba347f037bffc938708f0a186b1ff66d42027050f609b06bc367c43ab77b92b476c06dbe812f32f902dc3aa0adbda55006565875cc089e30dba2a649e73a6c91fb015d1127ca004c6678f0760ab4b5a6a37666f1b5dd32a91892153769d4be8a8ee8125ea1f840ebb2dd4ca01a9be433b1c5cf441f704f02e98e2ef48daa2353cb7c7c990b451d06a99eefe79231d13744f9ca044c7212de9e4d78af50805b48c2b6afac6ec497bc9d2f3204f4f2e9852d6ec0c4ecc82728eb02858ea3ec55e60b18c723b9407de281724bfa1760b7a93bf4cb067a4f2eaf16d7fe5b39be6d560b98bf8493bfcdc226eb3ce698af1d8fa7d32166b0627652086c7e01ae8f599341888cd4825ea9fde682d4f74d8fe9aa6b9d2a845eeacb30cde228bf8f018cd6d427c3b28f5748b046efd689542d9a4f6c84130458dbabc9df9fcf2b359ce5426062b1e7efbc37335e2b498163e0ba20fcacbcbd33bfbb25439bbc715f5df6b4643d47fdf479817f5d392cc3f39717ef3ef1fe2110ed5d7988782770ef08bad7625ae4208cfd3f5cd6cc04ba19ef48a7a11795b983ce0264a20ec850e11ff63a6cc589e09f7edf46c18fd72c545661ef9eac54028d9849bec0d88942636b37632ec22e70ea5aaa9566fc489a88a4b4c15138550018178e8900db4b7cff378db2fa43db0b8e9c405586736c26ad9566d36b12431474c383d191342a226182735a225ef7aea8e6f2b0d22d2ba92c6fe645341cdfb29b1d0961d073c1ac6233bdfb53ef8352c5d7285456046f2bac77ebac039c4f5dc6225815401291ea80e5250c361b156e78c570339f5048f62c5b69ec4d797d2114c02cf0123cb62ec92f480271587f0c64d98ef0e0ab0aa2d9c713bf7b74ab2e04d8a662921ff809c9b15b4f2a190462d7b4dda99fda98511ec260d1f22a2a69ef2406d06a0fc207c793ce9a479cc19335c746d48aa29022face0af22f158e2c4369d5a9747b43d12954bb2b82e8e042178b3c24c0b494a634c4b71d106693870a84fbaac03e40591582295bb94e256d7e075da00e78a4d2ff2b3dd9c6eb125c4493612f2c74070dd0487329cfb81244ab9bcb25646e40c3229f8e73a55429c8678d1b872ed674aac356c124d734f66e1cace3e5ecf8235a7734ba37c82c173ea43a2f92bc004e471a343be6fe7f404fb5a821365f32781bfb85347d25fd441d15e577d20901f53704195e8affc03368c0b3bd60aabe9ef14e854db1c9e6cd738d19ef1d20d506f7395fd096de963fee0ab7153be49322416426318aade21f20c02d7656d7bdc9aa54c7bc65e01ae7e0a92046c37385cf24bef7b860f2bfc18f28af6e0fb55c85f1c145a2aa1367daf479c326ff49b4e5f3e658057fe42af469aa29485c8a00cfc12da2d901058ffd2ed50915e76caa917b0e67e17fc5f29157694c30d798898af7f95a0ba07d91947c27199818283e8ce3a0adde1a4432c871e5322d863afd96b2159eb014ef98c6db40d6504355584d8fc47e249039cfffe1d1265ec037820a7a50613d4276caee2974e2e7b3d1651aea7855b0dbdd5555c29ca95f0bf919e6a5b0505148c5695193153aec27bdb02ce8da800a94ab0050fdebbc4c4ff50a169b9f262fca3cef3158bc844a9850ceed6ccee5f671bce70855a8efcee9a9543636c96cbbe6bd62a8b3a50259e8bf76118439f1eedc7d4e6c4aec08fcdd00184282b3d14624c9e0bf8f9c8b9b419bbd4bf3e02025f65b0828c94c4e2d77ca2e4f2781adac547b8fb4d9c75803ab55d72241945473b17dc819b4065f76469008d39825ebe36b9723014f72e528b9c1a083ddaba882a82decc88ff7c98c56e11ab722c79498ea1ce8ee53f367c23645eb78d0df19e6eb1803c8789455c72b2f2b087dc6b96c163f3cc17aec950b38ad776897c4af84afeadae1f0f07c5fc57d6fe7f0498e97c5843aab30da8d9bd8591d52252efd3ad13447cd210fed5985165714d871771a338e4cdf3afd9f8aab2abb7abb54b3096fed9ba818898fefd88db1973808a2c9aacda2515382375738cd3198624e45695fc3f9eb7b773c6373b8cd6cb749ea40bfa42911bdc366cf807a226a6d6020c3a4e838c6b0e1a30cff1c4215533430533d900c7aec49a172647246b02ca14fffdd9758a588423ea8a0c543b77edffb2147aa1801c4303abd780b21a3767341c78cd6ae526dd33a2bcce1c0cce433f08940df280da20a226da5e1f9dfc785b921e6e4c122f87d173999bf72c7a2b1afb925147dc823e9f0db2f3f234ee67d753d8ff2fbc54844fa1db423a45bda9b800680969902026de586b9684ecf6f117a38de98f2ccb4fc30028ab0464d9b781c95b10733f7c93ad61b0f848e8041ee4af5dc0724080836c65d015ba81777aa0ba484cb05e54ccb78bc32a8663a5379672e8ec9bd85d766b482ac661942a2f129f756f806577a09982fb15a39c75230ea1813f2b0f1e7c886100575521a230adbb3d466c020c8c943d7af51df009833d4cae36b2dee8fee49ce5bb892063a98aa9db5aadfc2014abcd69860c851a51afce466e09d0a111a80a331b62bcbd90ce44cda1535e7d8ce1dd3f6aec2b759edc4a258ddb87cc17f50214063f2b8976450aa2c8f07ef7b13152cc0760359a441ab31136554b8fc901ad9e08b312bfb180de3f9cf90e93b5d15a6be4f5482a528d194ce9be31274d185dfd9a8acc9866146393780d55af30f9ede8aa16093477991f1eeb9dab270c64b586496f017c5b80fb5c796faa2e5964d33ec8b61785a2c8b4b725d2033cbeca9a76a1183fd7456628e96cc4787a2c53b04519e5257d4be45d7142b52a733c19a6fee2eb36f67129d315521499a03943bb8d25d869c2f7beecc71102e31b9be1f5c5c999a172647d6003833b5a6f89f3d1ecfc66cf0dd78959b60eaf79adecbc0d7ff6da7cdd4edd85b75d2306445a3ab6dee5d08c1d2ce4a2be5944a962bc03aecb29edc15de96c16cedb810af7d4ca841f940c7a404f7ff5b8fd208ee9009952d87d783464e8611afd05a8767cb90ec3dcd1c697bdf5eb54776aafb7e29d1125642524a7312bec6ab442eea2ab5d9e4ad2cf98108a24a4fe9847611cc5b758470bd93d1af444e7d23f1d883b8b94944191993edf45eb862d37ff1819d87b1c2b8a3bd47dc589bc36d26a2ec2190b97970a29af2fa6d89edb90fb4785142ab8b4f250b0e6a4086f7d4397665c1e4840a9d809bec3b306aa8e5530d69da39289443876b79ffabdce5d4a774acab008efcedaaa671dba891ab013dff640c9799e572e1768e49d91e7abb6110e4e50109e2ba94aa42b3891cba732061a1c341c43f8dcc6242a77f18c7beeac74e4d021454cf1ade2dfdfa61384bb8c0b51913f29657cd0139f9fbdc359424a65a5737c9e2d36d7f44d8b47e4474fbfd5919718f7e95e4fa3b5eaca5cd1f6dcb68cf559e3d3d964db726ce5ae56bc624b301c94d4083411458e8a3acfb37606397dcc70e6a4a1a42b19caaedb0fc77fd41b6bd91541a28e9b2d44130ae38749bb0c6d37a2f00186726af1259b993f7c8965a87b6fe16bb8049fbbb592fa48c90def4b61d95fc123d948ece30e489626b4e004e183dbac2f58554c9e3c3428596ff284cc8ab5f7b3dddb3736dc4212744624a0c7589c0526b3b03ce88a57bb02bcb70f8daf230a56938337f1143dac4367e61ad80e1847bb594b39ee829acd0c2e739a21cda51797a8b96e907fe29bd0dc97697538ebc4b6688e3f61e7172980afe0048d9db619b71f11e087f9a364ffa75183f19c3e6d4d9e2cb9f4379d4f5d1495a6daeefcd2c84931bf9bbce7f0f207496cbfbe28770c821f3c35ad6400808fd7c66fc4eba0a3cc03c7a25e47b8de9579553b45eabc1e09db7ef86362689f7da2352a6ec14cd9bb37387c253705a29444260a1ababa3c6f84885899a1b4189fee300136b44ffd3157ea465d5616883ae3c35ce5154c03b61a9d4a38a88a49415940e37ae0ceaaedcdcccfca399b9476d8258365a7bb08310be74b5a39bc7a7cb12429454720f74f28fc17d623b266742d8ddaafa72047c7750cc98b2c6c4c961a4" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c70000101112131415161718191a1b766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfef94177bb023d9d7c5100833a3118c84c3622cffdfcf9ef269ab3301b3f743188f72cdcdec411e43f9e3b6ff4726081181a1862d419cec048c04a6740f6bc3dc40971f31f3d75eb2876381215eae53ba1bc3df6ff2732974b93e28dd4b3aebfefc9ef64b302a500b3ba6d9d2b1e80bd41e9e01083165b0ce61ae0568bfdd0636e8bb172c54541023f120fbd80918f5db8b1c22ba347f037bffc938708f0a186b1ff66d42027050f609b06bc367c43ab77b92b476c06dbe812f32f902dc3aa0adbda55006565875cc089e30dba2a649e73a6c91fb015d1127ca004c6678f0760ab4b5a6a37666f1b5dd32a91892153769d4be8a8ee8125ea1f840ebb2dd4ca01a9be433b1c5cf441f704f02e98e2ef48daa2353cb7c7c990b451d06a99eefe79231d13744f9ca044c7212de9e4d78af50805b48c2b6afac6ec497bc9d2f3204f4f2e9852d6ec0c4ecc82728eb02858ea3ec55e60b18c723b9407de281724bfa1760b7a93bf4cb067a4f2eaf16d7fe5b39be6d560b98bf8493bfcdc226eb3ce698af1d8fa7d32166b0627652086c7e01ae8f599341888cd4825ea9fde682d4f74d8fe9aa6b9d2a845eeacb30cde228bf8f018cd6d427c3b28f5748b046efd689542d9a4f6c84130458dbabc9df9fcf2b359ce5426062b1e7efbc37335e2b498163e0ba20fcacbcbd33bfbb25439bbc715f5df6b4643d47fdf479817f5d392cc3f39717ef3ef1fe2110ed5d7988782770ef08bad7625ae4208cfd3f5cd6cc04ba19ef48a7a11795b983ce0264a20ec850e11ff63a6cc589e09f7edf46c18fd72c545661ef9eac54028d9849bec0d88942636b37632ec22e70ea5aaa9566fc489a88a4b4c15138550018178e8900db4b7cff378db2fa43db0b8e9c405586736c26ad9566d36b12431474c383d191342a226182735a225ef7aea8e6f2b0d22d2ba92c6fe645341cdfb29b1d0961d073c1ac6233bdfb53ef8352c5d7285456046f2bac77ebac039c4f5dc6225815401291ea80e5250c361b156e78c570339f5048f62c5b69ec4d797d2114c02cf0123cb62ec92f480271587f0c64d98ef0e0ab0aa2d9c713bf7b74ab2e04d8a662921ff809c9b15b4f2a190462d7b4dda99fda98511ec260d1f22a2a69ef2406d06a0fc207c793ce9a479cc19335c746d48aa29022face0af22f158e2c4369d5a9747b43d12954bb2b82e8e042178b3c24c0b494a634c4b71d106693870a84fbaac03e40591582295bb94e256d7e075da00e78a4d2ff2b3dd9c6eb125c4493612f2c74070dd0487329cfb81244ab9bcb25646e40c3229f8e73a55429c8678d1b872ed674aac356c124d734f66e1cace3e5ecf8235a7734ba37c82c173ea43a2f92bc004e471a343be6fe7f404fb5a821365f32781bfb85347d25fd441d15e577d20901f53704195e8affc03368c0b3bd60aabe9ef14e854db1c9e6cd738d19ef1d20d506f7395fd096de963fee0ab7153be49322416426318aade21f20c02d7656d7bdc9aa54c7bc65e01ae7e0a92046c37385cf24bef7b860f2bfc18f28af6e0fb55c85f1c145a2aa1367daf479c326ff49b4e5f3e658057fe42af469aa29485c8a00cfc12da2d901058ffd2ed50915e76caa917b0e67e17fc5f29157694c30d798898af7f95a0ba07d91947c27199818283e8ce3a0adde1a4432c871e5322d863afd96b2159eb014ef98c6db40d6504355584d8fc47e249039cfffe1d1265ec037820a7a50613d4276caee2974e2e7b3d1651aea7855b0dbdd5555c29ca95f0bf919e6a5b0505148c5695193153aec27bdb02ce8da800a94ab0050fdebbc4c4ff50a169b9f262fca3cef3158bc844a9850ceed6ccee5f671bce70855a8efcee9a9543636c96cbbe6bd62a8b3a50259e8bf76118439f1eedc7d4e6c4aec08fcdd00184282b3d14624c9e0bf8f9c8b9b419bbd4bf3e02025f65b0828c94c4e2d77ca2e4f2781adac547b8fb4d9c75803ab55d72241945473b17dc819b4065f76469008d39825ebe36b9723014f72e528b9c1a083ddaba882a82decc88ff7c98c56e11ab722c79498ea1ce8ee53f367c23645eb78d0df19e6eb1803c8789455c72b2f2b087dc6b96c163f3cc17aec950b38ad776897c4af84afeadae1f0f07c5fc57d6fe7f0498e97c5843aab30da8d9bd8591d52252efd3ad13447cd210fed5985165714d871771a338e4cdf3afd9f8aab2abb7abb54b3096fed9ba818898fefd88db1973808a2c9aacda2515382375738cd3198624e45695fc3f9eb7b773c6373b8cd6cb749ea40bfa42911bdc366cf807a226a6d6020c3a4e838c6b0e1a30cff1c4215533430533d900c7aec49a172647246b02ca14fffdd9758a588423ea8a0c543b77edffb2147aa1801c4303abd780b21a3767341c78cd6ae526dd33a2bcce1c0cce433f08940df280da20a226da5e1f9dfc785b921e6e4c122f87d173999bf72c7a2b1afb925147dc823e9f0db2f3f234ee67d753d8ff2fbc54844fa1db423a45bda9b800680969902026de586b9684ecf6f117a38de98f2ccb4fc30028ab0464d9b781c95b10733f7c93ad61b0f848e8041ee4af5dc0724080836c65d015ba81777aa0ba484cb05e54ccb78bc32a8663a5379672e8ec9bd85d766b482ac661942a2f129f756f806577a09982fb15a39c75230ea1813f2b0f1e7c886100575521a230adbb3d466c020c8c943d7af51df009833d4cae36b2dee8fee49ce5bb892063a98aa9db5aadfc2014abcd69860c851a51afce672fb80061b7f8ead67b038c4c416f951dc1428b6c0af3d8ab2badfb516f5dcc95cc0a761c76e51474e6da2af93764cab60971f8e77121d23ce64044ab15cad3002694eb6f59f0fd8e79c2b22e80f16ecf6c3918f355343bbba47139ae4719199ce80c83e62c785c5dfa99dff8b275e21778dd541aa548dd89dba7dc34977def3efb8c1a971c141644f8bf41fff8900bac68aabbafd854c33fe856f2d4c218b785940243bf1c6a8b0562387f8745378df8ed45486a2d764555fc8491acca624db15235da92bc88978f521bc636674c26002780d8e1e9674a0ca3c8a9d2774e0d47803392ab6b1135d3a9794473143c41c397fadbe99933c03d9cd76fbd06f86846cf8ff8ac1decbdcfe0ba9c382f0dc5332ca713d05aeab76c5711ed7d2d44c2ad4a247d033d87ae1b285854ecd90d677f0e81ea1324ea940e25bc6a30ecbd4468bda32df9f069431d6cf9b564c8603a1de0f916acba3c720c9145351de49897572affb7b33800a4e52504a614defc8fd5f63f232d4d5e5b03af29b03ad5f44e3897118a90424d329f987ccad0b566542fdd99eaeb2534f160c98e7f959b107dd71a1969995adc3f4bba526d05986d5663ca9f22390bf6c74b39b94f463dff0bd07f360101beb864f3e20227c5686edd2384753cca29c12dd8d9bf7626b79aef2481039d437679642867062feacd6d9ffba70a4913181f3ecb6f769c3b992ab1333f1311a9a99d659fc728673f31f6d875314e1e50413b3a28cbb41b39b43eb7d767aecdb59a73d8dca7442ab730ed6bfefb5111c554d10db07dc62e5e2023a4bbed6b5151fe48756c87337f7abcb318638a7164f2faae0d36b6944d4bb7c5377f1f857166d847184f6fa2d5fcaabc34970cf60884ddf647d9b4dac0de24cf36bdc22b230ff40069b595455e8b8f5a02466748cc1970064051843b087b1ecb7f64bd65cb2b5ab651527e8ae855928b83c7489bec8866c22002b4c3bab1a639392e0f4a45e82b6fb0cea9851edbf593dfbdd9e88effb058d8c05399193b56fb5de3b2f5a1d4c5725a2b2e28151d498cd4cd581fb3a50c9e747634fcca07570ce2b3e556d5007798ed5416d2a02a886ab2dad2fc337e09bab70a8692318530755dade307f509b8da79c63f599b385b172bfd6d5fc24f212d41e7b7b8a8df206f9238126d4729f69bfb3446794b2ee0666779818f00e1dcfd661a85aec1a0045b03758c6471c3a19c6fc85c1fadc9a436b8ba4df1f4120b6f7bd2b9f931cfeb2ab205e067ad2cce4e3ca2b8e16c2d260f77c205fb6364bea4ab810378c653d559b08e88278fc358e6798edd7ba81d4941e0185cbe0072fe7a70cc89fa573879b40220576003f3fe6beb7b8c4f763ced79a510299e5e5175b6d52b1d51f7fe328856f6fc0a33d34c45452b23939e69dde949ba59f13d55167bf06a1bc9d9c87eeac818a6e7c99463a6a60e6da6de43ff5ed82d8e485561fd60960172bbfc3d1602f6860157ef04614052557177f6430aa2cfc7bb8ea143a1244cd21db9e906522147cbfd2a141e566c05d3cc6fdb20357efc8618c1e53a2896d2cd7335165380f54419f714278301fb16b0e50288e77ed887aa04aad0a2459618f8e2605590d012051a79c020b95a1ef025587299b34eee3228705cd03a1fef63303e3939ecb174dcb0ccfc938123dcc4d4b516f70cca86b61a0e6a3052eae4023c34e46a166b6e64a18ba816280883bf47160ce9734cb6983e428d66075350ce276045d41" }, { "category": "trailing_bytes", "entrypoint": "bundle", "expected_failure": "aead_authentication", "id": "bundle-trailing-byte", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1b766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfef94177bb023d9d7c5100833a3118c84c3622cffdfcf9ef269ab3301b3f743188f72cdcdec411e43f9e3b6ff4726081181a1862d419cec048c04a6740f6bc3dc40971f31f3d75eb2876381215eae53ba1bc3df6ff2732974b93e28dd4b3aebfefc9ef64b302a500b3ba6d9d2b1e80bd41e9e01083165b0ce61ae0568bfdd0636e8bb172c54541023f120fbd80918f5db8b1c22ba347f037bffc938708f0a186b1ff66d42027050f609b06bc367c43ab77b92b476c06dbe812f32f902dc3aa0adbda55006565875cc089e30dba2a649e73a6c91fb015d1127ca004c6678f0760ab4b5a6a37666f1b5dd32a91892153769d4be8a8ee8125ea1f840ebb2dd4ca01a9be433b1c5cf441f704f02e98e2ef48daa2353cb7c7c990b451d06a99eefe79231d13744f9ca044c7212de9e4d78af50805b48c2b6afac6ec497bc9d2f3204f4f2e9852d6ec0c4ecc82728eb02858ea3ec55e60b18c723b9407de281724bfa1760b7a93bf4cb067a4f2eaf16d7fe5b39be6d560b98bf8493bfcdc226eb3ce698af1d8fa7d32166b0627652086c7e01ae8f599341888cd4825ea9fde682d4f74d8fe9aa6b9d2a845eeacb30cde228bf8f018cd6d427c3b28f5748b046efd689542d9a4f6c84130458dbabc9df9fcf2b359ce5426062b1e7efbc37335e2b498163e0ba20fcacbcbd33bfbb25439bbc715f5df6b4643d47fdf479817f5d392cc3f39717ef3ef1fe2110ed5d7988782770ef08bad7625ae4208cfd3f5cd6cc04ba19ef48a7a11795b983ce0264a20ec850e11ff63a6cc589e09f7edf46c18fd72c545661ef9eac54028d9849bec0d88942636b37632ec22e70ea5aaa9566fc489a88a4b4c15138550018178e8900db4b7cff378db2fa43db0b8e9c405586736c26ad9566d36b12431474c383d191342a226182735a225ef7aea8e6f2b0d22d2ba92c6fe645341cdfb29b1d0961d073c1ac6233bdfb53ef8352c5d7285456046f2bac77ebac039c4f5dc6225815401291ea80e5250c361b156e78c570339f5048f62c5b69ec4d797d2114c02cf0123cb62ec92f480271587f0c64d98ef0e0ab0aa2d9c713bf7b74ab2e04d8a662921ff809c9b15b4f2a190462d7b4dda99fda98511ec260d1f22a2a69ef2406d06a0fc207c793ce9a479cc19335c746d48aa29022face0af22f158e2c4369d5a9747b43d12954bb2b82e8e042178b3c24c0b494a634c4b71d106693870a84fbaac03e40591582295bb94e256d7e075da00e78a4d2ff2b3dd9c6eb125c4493612f2c74070dd0487329cfb81244ab9bcb25646e40c3229f8e73a55429c8678d1b872ed674aac356c124d734f66e1cace3e5ecf8235a7734ba37c82c173ea43a2f92bc004e471a343be6fe7f404fb5a821365f32781bfb85347d25fd441d15e577d20901f53704195e8affc03368c0b3bd60aabe9ef14e854db1c9e6cd738d19ef1d20d506f7395fd096de963fee0ab7153be49322416426318aade21f20c02d7656d7bdc9aa54c7bc65e01ae7e0a92046c37385cf24bef7b860f2bfc18f28af6e0fb55c85f1c145a2aa1367daf479c326ff49b4e5f3e658057fe42af469aa29485c8a00cfc12da2d901058ffd2ed50915e76caa917b0e67e17fc5f29157694c30d798898af7f95a0ba07d91947c27199818283e8ce3a0adde1a4432c871e5322d863afd96b2159eb014ef98c6db40d6504355584d8fc47e249039cfffe1d1265ec037820a7a50613d4276caee2974e2e7b3d1651aea7855b0dbdd5555c29ca95f0bf919e6a5b0505148c5695193153aec27bdb02ce8da800a94ab0050fdebbc4c4ff50a169b9f262fca3cef3158bc844a9850ceed6ccee5f671bce70855a8efcee9a9543636c96cbbe6bd62a8b3a50259e8bf76118439f1eedc7d4e6c4aec08fcdd00184282b3d14624c9e0bf8f9c8b9b419bbd4bf3e02025f65b0828c94c4e2d77ca2e4f2781adac547b8fb4d9c75803ab55d72241945473b17dc819b4065f76469008d39825ebe36b9723014f72e528b9c1a083ddaba882a82decc88ff7c98c56e11ab722c79498ea1ce8ee53f367c23645eb78d0df19e6eb1803c8789455c72b2f2b087dc6b96c163f3cc17aec950b38ad776897c4af84afeadae1f0f07c5fc57d6fe7f0498e97c5843aab30da8d9bd8591d52252efd3ad13447cd210fed5985165714d871771a338e4cdf3afd9f8aab2abb7abb54b3096fed9ba818898fefd88db1973808a2c9aacda2515382375738cd3198624e45695fc3f9eb7b773c6373b8cd6cb749ea40bfa42911bdc366cf807a226a6d6020c3a4e838c6b0e1a30cff1c4215533430533d900c7aec49a172647246b02ca14fffdd9758a588423ea8a0c543b77edffb2147aa1801c4303abd780b21a3767341c78cd6ae526dd33a2bcce1c0cce433f08940df280da20a226da5e1f9dfc785b921e6e4c122f87d173999bf72c7a2b1afb925147dc823e9f0db2f3f234ee67d753d8ff2fbc54844fa1db423a45bda9b800680969902026de586b9684ecf6f117a38de98f2ccb4fc30028ab0464d9b781c95b10733f7c93ad61b0f848e8041ee4af5dc0724080836c65d015ba81777aa0ba484cb05e54ccb78bc32a8663a5379672e8ec9bd85d766b482ac661942a2f129f756f806577a09982fb15a39c75230ea1813f2b0f1e7c886100575521a230adbb3d466c020c8c943d7af51df009833d4cae36b2dee8fee49ce5bb892063a98aa9db5aadfc2014abcd69860c851a51afce466e09d0a111a80a331b62bcbd90ce44cda1535e7d8ce1dd3f6aec2b759edc4a258ddb87cc17f50214063f2b8976450aa2c8f07ef7b13152cc0760359a441ab31136554b8fc901ad9e08b312bfb180de3f9cf90e93b5d15a6be4f5482a528d194ce9be31274d185dfd9a8acc9866146393780d55af30f9ede8aa16093477991f1eeb9dab270c64b586496f017c5b80fb5c796faa2e5964d33ec8b61785a2c8b4b725d2033cbeca9a76a1183fd7456628e96cc4787a2c53b04519e5257d4be45d7142b52a733c19a6fee2eb36f67129d315521499a03943bb8d25d869c2f7beecc71102e31b9be1f5c5c999a172647d6003833b5a6f89f3d1ecfc66cf0dd78959b60eaf79adecbc0d7ff6da7cdd4edd85b75d2306445a3ab6dee5d08c1d2ce4a2be5944a962bc03aecb29edc15de96c16cedb810af7d4ca841f940c7a404f7ff5b8fd208ee9009952d87d783464e8611afd05a8767cb90ec3dcd1c697bdf5eb54776aafb7e29d1125642524a7312bec6ab442eea2ab5d9e4ad2cf98108a24a4fe9847611cc5b758470bd93d1af444e7d23f1d883b8b94944191993edf45eb862d37ff1819d87b1c2b8a3bd47dc589bc36d26a2ec2190b97970a29af2fa6d89edb90fb4785142ab8b4f250b0e6a4086f7d4397665c1e4840a9d809bec3b306aa8e5530d69da39289443876b79ffabdce5d4a774acab008efcedaaa671dba891ab013dff640c9799e572e1768e49d91e7abb6110e4e50109e2ba94aa42b3891cba732061a1c341c43f8dcc6242a77f18c7beeac74e4d021454cf1ade2dfdfa61384bb8c0b51913f29657cd0139f9fbdc359424a65a5737c9e2d36d7f44d8b47e4474fbfd5919718f7e95e4fa3b5eaca5cd1f6dcb68cf559e3d3d964db726ce5ae56bc624b301c94d4083411458e8a3acfb37606397dcc70e6a4a1a42b19caaedb0fc77fd41b6bd91541a28e9b2d44130ae38749bb0c6d37a2f00186726af1259b993f7c8965a87b6fe16bb8049fbbb592fa48c90def4b61d95fc123d948ece30e489626b4e004e183dbac2f58554c9e3c3428596ff284cc8ab5f7b3dddb3736dc4212744624a0c7589c0526b3b03ce88a57bb02bcb70f8daf230a56938337f1143dac4367e61ad80e1847bb594b39ee829acd0c2e739a21cda51797a8b96e907fe29bd0dc97697538ebc4b6688e3f61e7172980afe0048d9db619b71f11e087f9a364ffa75183f19c3e6d4d9e2cb9f4379d4f5d1495a6daeefcd2c84931bf9bbce7f0f207496cbfbe28770c821f3c35ad6400808fd7c66fc4eba0a3cc03c7a25e47b8de9579553b45eabc1e09db7ef86362689f7da2352a6ec14cd9bb37387c253705a29444260a1ababa3c6f84885899a1b4189fee300136b44ffd3157ea465d5616883ae3c35ce5154c03b61a9d4a38a88a49415940e37ae0ceaaedcdcccfca399b9476d8258365a7bb08310be74b5a39bc7a7cb12429454720f74f28fc17d623b266742d8ddaafa72047c7750cc98b2c6c4c961a400" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1b766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfef94177bb023d9d7c5100833a3118c84c3622cffdfcf9ef269ab3301b3f743188f72cdcdec411e43f9e3b6ff4726081181a1862d419cec048c04a6740f6bc3dc40971f31f3d75eb2876381215eae53ba1bc3df6ff2732974b93e28dd4b3aebfefc9ef64b302a500b3ba6d9d2b1e80bd41e9e01083165b0ce61ae0568bfdd0636e8bb172c54541023f120fbd80918f5db8b1c22ba347f037bffc938708f0a186b1ff66d42027050f609b06bc367c43ab77b92b476c06dbe812f32f902dc3aa0adbda55006565875cc089e30dba2a649e73a6c91fb015d1127ca004c6678f0760ab4b5a6a37666f1b5dd32a91892153769d4be8a8ee8125ea1f840ebb2dd4ca01a9be433b1c5cf441f704f02e98e2ef48daa2353cb7c7c990b451d06a99eefe79231d13744f9ca044c7212de9e4d78af50805b48c2b6afac6ec497bc9d2f3204f4f2e9852d6ec0c4ecc82728eb02858ea3ec55e60b18c723b9407de281724bfa1760b7a93bf4cb067a4f2eaf16d7fe5b39be6d560b98bf8493bfcdc226eb3ce698af1d8fa7d32166b0627652086c7e01ae8f599341888cd4825ea9fde682d4f74d8fe9aa6b9d2a845eeacb30cde228bf8f018cd6d427c3b28f5748b046efd689542d9a4f6c84130458dbabc9df9fcf2b359ce5426062b1e7efbc37335e2b498163e0ba20fcacbcbd33bfbb25439bbc715f5df6b4643d47fdf479817f5d392cc3f39717ef3ef1fe2110ed5d7988782770ef08bad7625ae4208cfd3f5cd6cc04ba19ef48a7a11795b983ce0264a20ec850e11ff63a6cc589e09f7edf46c18fd72c545661ef9eac54028d9849bec0d88942636b37632ec22e70ea5aaa9566fc489a88a4b4c15138550018178e8900db4b7cff378db2fa43db0b8e9c405586736c26ad9566d36b12431474c383d191342a226182735a225ef7aea8e6f2b0d22d2ba92c6fe645341cdfb29b1d0961d073c1ac6233bdfb53ef8352c5d7285456046f2bac77ebac039c4f5dc6225815401291ea80e5250c361b156e78c570339f5048f62c5b69ec4d797d2114c02cf0123cb62ec92f480271587f0c64d98ef0e0ab0aa2d9c713bf7b74ab2e04d8a662921ff809c9b15b4f2a190462d7b4dda99fda98511ec260d1f22a2a69ef2406d06a0fc207c793ce9a479cc19335c746d48aa29022face0af22f158e2c4369d5a9747b43d12954bb2b82e8e042178b3c24c0b494a634c4b71d106693870a84fbaac03e40591582295bb94e256d7e075da00e78a4d2ff2b3dd9c6eb125c4493612f2c74070dd0487329cfb81244ab9bcb25646e40c3229f8e73a55429c8678d1b872ed674aac356c124d734f66e1cace3e5ecf8235a7734ba37c82c173ea43a2f92bc004e471a343be6fe7f404fb5a821365f32781bfb85347d25fd441d15e577d20901f53704195e8affc03368c0b3bd60aabe9ef14e854db1c9e6cd738d19ef1d20d506f7395fd096de963fee0ab7153be49322416426318aade21f20c02d7656d7bdc9aa54c7bc65e01ae7e0a92046c37385cf24bef7b860f2bfc18f28af6e0fb55c85f1c145a2aa1367daf479c326ff49b4e5f3e658057fe42af469aa29485c8a00cfc12da2d901058ffd2ed50915e76caa917b0e67e17fc5f29157694c30d798898af7f95a0ba07d91947c27199818283e8ce3a0adde1a4432c871e5322d863afd96b2159eb014ef98c6db40d6504355584d8fc47e249039cfffe1d1265ec037820a7a50613d4276caee2974e2e7b3d1651aea7855b0dbdd5555c29ca95f0bf919e6a5b0505148c5695193153aec27bdb02ce8da800a94ab0050fdebbc4c4ff50a169b9f262fca3cef3158bc844a9850ceed6ccee5f671bce70855a8efcee9a9543636c96cbbe6bd62a8b3a50259e8bf76118439f1eedc7d4e6c4aec08fcdd00184282b3d14624c9e0bf8f9c8b9b419bbd4bf3e02025f65b0828c94c4e2d77ca2e4f2781adac547b8fb4d9c75803ab55d72241945473b17dc819b4065f76469008d39825ebe36b9723014f72e528b9c1a083ddaba882a82decc88ff7c98c56e11ab722c79498ea1ce8ee53f367c23645eb78d0df19e6eb1803c8789455c72b2f2b087dc6b96c163f3cc17aec950b38ad776897c4af84afeadae1f0f07c5fc57d6fe7f0498e97c5843aab30da8d9bd8591d52252efd3ad13447cd210fed5985165714d871771a338e4cdf3afd9f8aab2abb7abb54b3096fed9ba818898fefd88db1973808a2c9aacda2515382375738cd3198624e45695fc3f9eb7b773c6373b8cd6cb749ea40bfa42911bdc366cf807a226a6d6020c3a4e838c6b0e1a30cff1c4215533430533d900c7aec49a172647246b02ca14fffdd9758a588423ea8a0c543b77edffb2147aa1801c4303abd780b21a3767341c78cd6ae526dd33a2bcce1c0cce433f08940df280da20a226da5e1f9dfc785b921e6e4c122f87d173999bf72c7a2b1afb925147dc823e9f0db2f3f234ee67d753d8ff2fbc54844fa1db423a45bda9b800680969902026de586b9684ecf6f117a38de98f2ccb4fc30028ab0464d9b781c95b10733f7c93ad61b0f848e8041ee4af5dc0724080836c65d015ba81777aa0ba484cb05e54ccb78bc32a8663a5379672e8ec9bd85d766b482ac661942a2f129f756f806577a09982fb15a39c75230ea1813f2b0f1e7c886100575521a230adbb3d466c020c8c943d7af51df009833d4cae36b2dee8fee49ce5bb892063a98aa9db5aadfc2014abcd69860c851a51afce672fb80061b7f8ead67b038c4c416f951dc1428b6c0af3d8ab2badfb516f5dcc95cc0a761c76e51474e6da2af93764cab60971f8e77121d23ce64044ab15cad3002694eb6f59f0fd8e79c2b22e80f16ecf6c3918f355343bbba47139ae4719199ce80c83e62c785c5dfa99dff8b275e21778dd541aa548dd89dba7dc34977def3efb8c1a971c141644f8bf41fff8900bac68aabbafd854c33fe856f2d4c218b785940243bf1c6a8b0562387f8745378df8ed45486a2d764555fc8491acca624db15235da92bc88978f521bc636674c26002780d8e1e9674a0ca3c8a9d2774e0d47803392ab6b1135d3a9794473143c41c397fadbe99933c03d9cd76fbd06f86846cf8ff8ac1decbdcfe0ba9c382f0dc5332ca713d05aeab76c5711ed7d2d44c2ad4a247d033d87ae1b285854ecd90d677f0e81ea1324ea940e25bc6a30ecbd4468bda32df9f069431d6cf9b564c8603a1de0f916acba3c720c9145351de49897572affb7b33800a4e52504a614defc8fd5f63f232d4d5e5b03af29b03ad5f44e3897118a90424d329f987ccad0b566542fdd99eaeb2534f160c98e7f959b107dd71a1969995adc3f4bba526d05986d5663ca9f22390bf6c74b39b94f463dff0bd07f360101beb864f3e20227c5686edd2384753cca29c12dd8d9bf7626b79aef2481039d437679642867062feacd6d9ffba70a4913181f3ecb6f769c3b992ab1333f1311a9a99d659fc728673f31f6d875314e1e50413b3a28cbb41b39b43eb7d767aecdb59a73d8dca7442ab730ed6bfefb5111c554d10db07dc62e5e2023a4bbed6b5151fe48756c87337f7abcb318638a7164f2faae0d36b6944d4bb7c5377f1f857166d847184f6fa2d5fcaabc34970cf60884ddf647d9b4dac0de24cf36bdc22b230ff40069b595455e8b8f5a02466748cc1970064051843b087b1ecb7f64bd65cb2b5ab651527e8ae855928b83c7489bec8866c22002b4c3bab1a639392e0f4a45e82b6fb0cea9851edbf593dfbdd9e88effb058d8c05399193b56fb5de3b2f5a1d4c5725a2b2e28151d498cd4cd581fb3a50c9e747634fcca07570ce2b3e556d5007798ed5416d2a02a886ab2dad2fc337e09bab70a8692318530755dade307f509b8da79c63f599b385b172bfd6d5fc24f212d41e7b7b8a8df206f9238126d4729f69bfb3446794b2ee0666779818f00e1dcfd661a85aec1a0045b03758c6471c3a19c6fc85c1fadc9a436b8ba4df1f4120b6f7bd2b9f931cfeb2ab205e067ad2cce4e3ca2b8e16c2d260f77c205fb6364bea4ab810378c653d559b08e88278fc358e6798edd7ba81d4941e0185cbe0072fe7a70cc89fa573879b40220576003f3fe6beb7b8c4f763ced79a510299e5e5175b6d52b1d51f7fe328856f6fc0a33d34c45452b23939e69dde949ba59f13d55167bf06a1bc9d9c87eeac818a6e7c99463a6a60e6da6de43ff5ed82d8e485561fd60960172bbfc3d1602f6860157ef04614052557177f6430aa2cfc7bb8ea143a1244cd21db9e906522147cbfd2a141e566c05d3cc6fdb20357efc8618c1e53a2896d2cd7335165380f54419f714278301fb16b0e50288e77ed887aa04aad0a2459618f8e2605590d012051a79c020b95a1ef025587299b34eee3228705cd03a1fef63303e3939ecb174dcb0ccfc938123dcc4d4b516f70cca86b61a0e6a3052eae4023c34e46a166b6e64a18ba816280883bf47160ce9734cb6983e428d66075350ce276045d4100" }, { "category": "outer_inner_identity", "entrypoint": "bundle", "expected_failure": "inner_bundle_id", "id": "bundle-inner-id-mismatch", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1c766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf947f780ca16af2d5f73fcb8b053a3ae1c3dea27994dc1a9cfbddd183d0d75658aeddf4db957813d089ad81abb16074425edf4cc4abeb769031a86e33ad0569d3421d3b2cc2039fd7191025939178a3f566dc86b4c6ec4ae375280370a7cd09a3fa13bcbffe19e6a428cf718c8a819dae6a8bc6faeb24a4168f28b3c1326365ad92231fd86ce0aa478793eea5df7726b164984a090fa099aa6c6964a3206532d0f9ed18bcf781b1f8e341058ac26941c99d6cd9d41cfbcea28fafd6436760c368f7eb33e5efde41a0c6e22e4cfcde60311256dcb52ac48a08e4471449df2b9bdfe4be7ef321376bcfaaf5dce4e98c41d5bfa147e62d23968ef54222afb59d5b9f021bd72c57dad8b97e2fe398e0e58807cdcb5ad2d6cabfcb4e8ad9f44292b855c2ff82f8c63d8fc5d3112c5ff37a4194e198dcb4cf408a8a44dab91f9c908d411df2f34426682f592ff8c72cc42b59161ca7fe6c8ae0557294dc2f87c26c7e8a2772d051d0e6936c32687450621d7949a4a43f84310b0e8761375367a84d63ca487465e651307b919b663a2207f4f04b8f80f544d1e45f4ad51f12dffc56d0dc0e0f8ca2c79170a0b2e1024611f46f14d869f7a4cf98333cbbf1155e446709c93df67cc4bdd7d6a0ef89feb9cd2e57d34acbe9e4bdeddc7f820bb5964cbe8640e062e1b354d766018853f015aa0aa45b41f3df4910f467990f48d8d149f623bf2317f6b49c1599796ac77d1b4a31be75ffe426bdc0f3f11d9373234be13c535451aa844599d52f5aeee313075f07da69897d97c534510cad10180131ebe8b9b354632207a4f8a8cc2704cecd61a27d93696cfbc29f0eadedbdd853b7f3a62a83d6128dbcb581ac0407bdc2e78bc0ba2ac04cf3ebd8a7f2ac4adccabeae94474c9eab21884442332cc70dcb2b3e06b6d423fee125655c38935850c7d64a35a1d17bab82405fff3c7f0b0fbf5ad721e068b7f179253f37395850a85ba7f767d80e23f198e73d5a2b72ea152e00194ab728016fa7e3a4c31f14470e5a61b95d6d9c3204b3886359cc39adf6aa2634c8c94962c73aa8546d1330b5500e150773a36e61d9cdd8ae8e2c539b27d591efb0ab45804c2d3dce2784774e8c62118740975455a5a64723cfd4f08b007c141d8d8b28e27344f558877dbdd9ece7cb5c8433e070a5504ab6760256a4450cb0dc107e5359b213a0cd285ad338b825ee22feb46f5ce03f8629799e93a0d12425138c172c8fad45df53c90c2d391af97bf602b9a50c8e872b00e3df6af1bf8a9e7fa845511c969f67f6245411a55caa0911b6fa5a677332575a145f5269c06fc2159f86999a43033ed1664ccf41c1a301bb29edf4466c8c5d736ae848ba329fdbfec7761b83262bd742086252ac23c49f82967c7ebc5983af5d94b79d46ed3a9f24171f58b949c6641ad3265cdf6df6037cfb4e84c91122b2619306217deb21fd25ba23af9af66f13f581c8a2970d204d69b84798e4ccbc0852b45b24ef9b7a01372c91b0584ce6c980288ee4b6052a7ecdf5e21b29d1f7b6ca263721735197af239ca467bc50dda17df9fad301cb958b34ea551e2b63a7ebfb657f92bc366aa26eb44e3b62dd489463bb9b87baecee49afb8286d1efd4fe256ebdb98976ae587d65884c9c527fa692298bf68c5b5cb6e9e4333bed08cd7a14810c296aafdbc469881d2de6a2aa4e4e692aafa516cc7f1b7a465f0cf6501325d7e3c46d19fdd10fcc23c43abd8e43dfe993ce7ddeb8f5e6c8913f6e3582346085e251fbb2ab620801d3893c717f70102df0f7f48eb8174a2ad9dc0acb50b24a4b85e2cbb54927357ca500c98fa2f4f849fbc55aae90aa4b18fbe0b2acd6366bf91bafac7f92c40d605acc467ba45da0ebff750b06893253ac7ec8126813875d7f329fadb8957de36dadaf925b199ae76a2ac7c78416ec9063e492d74d32e375477e7f3437ad7d9ac531d117cd3a99e3e6419b65764b982c4e7a95a6e409a45a3a2bf456d337ee645c9b515d22ce3d94504ddd7b660b2aafe078eb94820822c8239535a8481d672e1976945706524c33a46bb053b1ab88e2221f2f98c88713706cd9454435bbab574e46cdf74ae6fb9973e832e01327859ae264d355612c82de26133722babc5d4d4325f8f1f806aa0f32709565cbe335d92641388848bb4bca58498d2ebce3200729fe1807b0643959498915a50fb52592319428a11ff74912910cae5add7fc80784c2a4d0c5702f3983828507c16173b276c2025c0fdc97362015677104f80178cc87b08d2f60bd4a0bc01fb46d96f6ff653efad9d1fbbbc20218ad99042d9f59ab08b8d4eb8c01a58a9a2a5aab9656e96b164a3a138e86755a171276ed714476a61947bda517861c941f7597a3c5ad44eaa0784e7e53ea3609b6345b1cc0793ec6de8abf46038ca1c9f708aaeeaa2017575d7bb37def63b76788698ddeb8064959a8188355a53f4b1c28175d9adefbcbae4a303ff65f731f0c915e7fc8662ef0e81434aaf4759cb07d43bc40ab7e6f6dfe507bfd539bb07df380ad642238bab029a477765b5648441b83aa3a279661871b5dac5581f32b1e56421687b772c72bb365bc9fa9cfaa317adf359eb29f6acb9962c6775f28dfa667fa243aee75ec9707a18cbc418ce9379de640787b633ca8176bf871fe7d32ac8fce33574521873a8c9fac6c60d26472b7d50addccebf2ef56f171d6e306d2a32299ee3f3809aedaf230e7bde25c80f845f6e2adaca2883bdb28e190e58d8855b607b3f03b69a018e6eb60aa45c7b2beeb6b02650399245cd695766d32c7b007123f1fd6e6f8ffd017e627abe155ea64f06676e3924641d7fcfbab501281e11f70db8d41aafd0780c62be8a3a52c44fabc66d0c5bab25062ba86031f9c0ac7dbbd0149c243ad78a3c58332e35e9da7e0070f6e33cca6dfb25e566b07b770ea4ce9fff65c26146297862ad878ff4a18db23a85535ee62acbda025624c1fb46ddde8ea8944793405aa7570d163dd53dab79f78af70a8ce50567caa87d7cfd6a7d2e977f51909ad3f488453bfa07bac2e1a64c26b69701c1ee0c8951593200129630740e0f137e4d9451532f49d91e03cafdfe32df26d25eb14a2035ca5301cd21b74238e926cd701e26bd150fd523f61998b5caae182c06d7973da4ff43e6f73ceb19627b160ae1ef7f3385e9425aa0a8783bab07bf3b1109e51d395eb64a8ba956a2b6af02b6c0f3c6c730a1d46169b1b50d866eb364a472287d5d419e6cd67da378a0d8cd9b25af1fc7973132a1fbc85b3a6cbb80a2e4968d660ef53d2d28f5acbc983f8b2ec0a8d99d0c7efd14e2cc6dadf496cb92db824792525d62464ae1939df90652b8c864075c4a1530b45bc6c7869bf35c65afb241110c92775ce3a9c92392c5f99fafdcaf293ea5e44f24b82edae783925b13289de7958c0a2a8353136b1297f7aa7cd74575c200f36673843412c786e5b99c21b7501d1a4485ece82530f54c4857609adde3abf70bb60b22a97dc914b8d85b659267e3283e835300fd6144739510f8e37f1c8fda7bf1c772723a9d2b603b3ebefa13edeaf802735ab07bd6908434a40ca56feb0600400d29205bfe741b2eacbb135fe6ccb45be474b255b74f06b81892cb51825af535954d17cf92518dfef189b1b0aec38ea8e962b8e76aa852499b39396d9618ef96fa6fa5900e728a695cf76f4db002761f211b608c4081dc8c5a5de0f85d231ea01cf640ad218eb10814ea1cb8c49fd3cdcaa8fd1f87e66fd42a71140ead1b91cb0ec4528f8973202ed6acae4654cf08e2d5609467cb7a4d4948e977b2b83cbe2e8ef8056af3dba74f216a2b105956cb08aa86f63607c449f5c729c4bd5535012f31765880460127b685587e1fe27921e4aaa93cc55ee947b1416128a9d5d41c32ac30338971ac452dca738bce904fb52b1c5e849f33ffc748d8515b590f150471c34c16735f73d3eba835adf70d5625c32ae24e3512942cd857c6d3c34dfd7901743444dcffb376779a63374381f290fb92454be38d905b2a0084ae5389a5f49daa66e62423cbcb9f965b08de2b283d26d3376a3bfebd2eb513c0709a80e6ca016cf0daf6916b87f7e26e374ced64f9581010b6e41c12b8e19c9c4f7ea2dc70bd98c0fadef12b8febaf95fefd176d31d42912fa2f648d2e59e0934e604cd51ee8478af16807efa163fc2f97aeec16de27ceba2518c56bd71ea48e482ce4875e022f9baa82f26aa815fb6dbe463716d52e1b1228547c3fcad6a7a63fd21553dab54764e9a1" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1c766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf947f780ca16af2d5f73fcb8b053a3ae1c3dea27994dc1a9cfbddd183d0d75658aeddf4db957813d089ad81abb16074425edf4cc4abeb769031a86e33ad0569d3421d3b2cc2039fd7191025939178a3f566dc86b4c6ec4ae375280370a7cd09a3fa13bcbffe19e6a428cf718c8a819dae6a8bc6faeb24a4168f28b3c1326365ad92231fd86ce0aa478793eea5df7726b164984a090fa099aa6c6964a3206532d0f9ed18bcf781b1f8e341058ac26941c99d6cd9d41cfbcea28fafd6436760c368f7eb33e5efde41a0c6e22e4cfcde60311256dcb52ac48a08e4471449df2b9bdfe4be7ef321376bcfaaf5dce4e98c41d5bfa147e62d23968ef54222afb59d5b9f021bd72c57dad8b97e2fe398e0e58807cdcb5ad2d6cabfcb4e8ad9f44292b855c2ff82f8c63d8fc5d3112c5ff37a4194e198dcb4cf408a8a44dab91f9c908d411df2f34426682f592ff8c72cc42b59161ca7fe6c8ae0557294dc2f87c26c7e8a2772d051d0e6936c32687450621d7949a4a43f84310b0e8761375367a84d63ca487465e651307b919b663a2207f4f04b8f80f544d1e45f4ad51f12dffc56d0dc0e0f8ca2c79170a0b2e1024611f46f14d869f7a4cf98333cbbf1155e446709c93df67cc4bdd7d6a0ef89feb9cd2e57d34acbe9e4bdeddc7f820bb5964cbe8640e062e1b354d766018853f015aa0aa45b41f3df4910f467990f48d8d149f623bf2317f6b49c1599796ac77d1b4a31be75ffe426bdc0f3f11d9373234be13c535451aa844599d52f5aeee313075f07da69897d97c534510cad10180131ebe8b9b354632207a4f8a8cc2704cecd61a27d93696cfbc29f0eadedbdd853b7f3a62a83d6128dbcb581ac0407bdc2e78bc0ba2ac04cf3ebd8a7f2ac4adccabeae94474c9eab21884442332cc70dcb2b3e06b6d423fee125655c38935850c7d64a35a1d17bab82405fff3c7f0b0fbf5ad721e068b7f179253f37395850a85ba7f767d80e23f198e73d5a2b72ea152e00194ab728016fa7e3a4c31f14470e5a61b95d6d9c3204b3886359cc39adf6aa2634c8c94962c73aa8546d1330b5500e150773a36e61d9cdd8ae8e2c539b27d591efb0ab45804c2d3dce2784774e8c62118740975455a5a64723cfd4f08b007c141d8d8b28e27344f558877dbdd9ece7cb5c8433e070a5504ab6760256a4450cb0dc107e5359b213a0cd285ad338b825ee22feb46f5ce03f8629799e93a0d12425138c172c8fad45df53c90c2d391af97bf602b9a50c8e872b00e3df6af1bf8a9e7fa845511c969f67f6245411a55caa0911b6fa5a677332575a145f5269c06fc2159f86999a43033ed1664ccf41c1a301bb29edf4466c8c5d736ae848ba329fdbfec7761b83262bd742086252ac23c49f82967c7ebc5983af5d94b79d46ed3a9f24171f58b949c6641ad3265cdf6df6037cfb4e84c91122b2619306217deb21fd25ba23af9af66f13f581c8a2970d204d69b84798e4ccbc0852b45b24ef9b7a01372c91b0584ce6c980288ee4b6052a7ecdf5e21b29d1f7b6ca263721735197af239ca467bc50dda17df9fad301cb958b34ea551e2b63a7ebfb657f92bc366aa26eb44e3b62dd489463bb9b87baecee49afb8286d1efd4fe256ebdb98976ae587d65884c9c527fa692298bf68c5b5cb6e9e4333bed08cd7a14810c296aafdbc469881d2de6a2aa4e4e692aafa516cc7f1b7a465f0cf6501325d7e3c46d19fdd10fcc23c43abd8e43dfe993ce7ddeb8f5e6c8913f6e3582346085e251fbb2ab620801d3893c717f70102df0f7f48eb8174a2ad9dc0acb50b24a4b85e2cbb54927357ca500c98fa2f4f849fbc55aae90aa4b18fbe0b2acd6366bf91bafac7f92c40d605acc467ba45da0ebff750b06893253ac7ec8126813875d7f329fadb8957de36dadaf925b199ae76a2ac7c78416ec9063e492d74d32e375477e7f3437ad7d9ac531d117cd3a99e3e6419b65764b982c4e7a95a6e409a45a3a2bf456d337ee645c9b515d22ce3d94504ddd7b660b2aafe078eb94820822c8239535a8481d672e1976945706524c33a46bb053b1ab88e2221f2f98c88713706cd9454435bbab574e46cdf74ae6fb9973e832e01327859ae264d355612c82de26133722babc5d4d4325f8f1f806aa0f32709565cbe335d92641388848bb4bca58498d2ebce3200729fe1807b0643959498915a50fb52592319428a11ff74912910cae5add7fc80784c2a4d0c5702f3983828507c16173b276c2025c0fdc97362015677104f80178cc87b08d2f60bd4a0bc01fb46d96f6ff653efad9d1fbbbc20218ad99042d9f59ab08b8d4eb8c01a58a9a2a5aab9656e96b164a3a138e86755a171276ed714476a61947bda517861c941f7597a3c5ad44eaa0784e7e53ea3609b6345b1cc0793ec6de8abf46038ca1c9f708aaeeaa2017575d7bb37def63b76788698ddeb8064959a8188355a53f4b1c28175d9adefbcbae4a303ff65f731f0c915e7fc8662ef0e81434aaf4759cb07d43bc40ab7e6f6dfe507bfd539bb07df380ad642238bab029a477765b5648441b83aa3a279661871b5dac5581f32b1e56421687b772c72bb365bc9fa9cfaa317adf359eb29f6acb9962c6775f28dfa667fa243aee75ec9707a18cbc418ce9379de640787b633ca8176bf871fe7d32ac8fce33574521873a8c9fac6c60d26472b7d50addccebf2ef56f171d6e306d2a32299ee3f3809aedaf230e7bde25c80f845f6e2adaca2883bdb28e190e5acc9e466c715a0db8cc079d61ab10b9417d2af3e7a8477060d651d06b187ecb477f1d6e3ef7ec6f0981f351696668a21412be57676b6f312b6a0f78dcafa807290f1ded03b1db0faed76f166bad9d215dcb43aaa063020db6210e6cb02168b9cdac6690fc028a2420d18b0d6e3e682df19a73006bafb82fcc7ae038b568be34750fa5d584fe62c85d6d347c6a97b680fba091e3229d405fe638c5d4574029c1c86dc0da8692be46847c68a3520d13278425b36af68ae555ddeb537c87b0651df16b7521996758838dc8fb87493a9c58eb95b8e2583bb4d819d98f008859563c181b85276f01001f7f2b9a5f03384dcb020286e5e653d327cf48ba502b38dd401ec133bf5227fc2dc6717828b34312d12bb101d9ec8aa318372dfb812ba4e545e7c60ae65f7a635607e1f4266896ef5541bdf87635e405be3a0a12e41a33629d578fa16c93b9a003aa91ebded732a1c66f67e4a300865d9879a07a12575c56a25ed278a37dba89d58335ad1fd5c8603cf7e08543220db781bff29d976d01e0362725f2a5d9c76edc7784bcc49f4366e575eecd70bbff8cc09dce894d9f5554074844b18499eb1a53f4d07c66504b0826bf41cdd9849bf54961beb3271f02c46a58ebeed162cb85f49fb4f7833f28a5fe49258918d7a19b8a1b1e2886bece9f0c3d984e43651cd8f5ab7dcc5e74c50acf4d6e803c28f689eab88070af480c194497e2e67026f84c7b7c7d9ed5d981f61c8a3926ac7dcc0ee9c043749167f1776f8d051bb0795c109710fef57f1083cc6af0c2002d6bca72242f23bcb50bf589fa02754cb077da869535a17eab61bb11045211286c43e617192eaaad1353e9cea358ee71bc5aab4409a90a92dd50e454fb6388491f888a10d4befc89aa9b82d598a8f364b9d65ca514518379346d8d41b485fc78be8d5e7c84265ef66e56ab0371151d307d8848bbed83555cfca9453b08a41ce44ea37498bc0827c101bccca5f9ccdd94cf1b82e66ace7b690c18a91b83945fca0494c46b385ec1a8ab5b2f9059b58c24807ac935555138fe73b7a547b020deef0178efdaeb5739183a035f4bc102b397f92c0cc84ee0d42fd5bc333b0f792c7250c75e407ef688586534ce6f37e4b0af3df468e401a9592125a9ce8d4721aa27289421a24b62c8728ad58b4eb2212075999b3bc5f647d7504708170d1374c35e187d0a65deeb9b1eb0f3056c0fc23dde7c31179429c206decbd448fd6b5e253a12c7b2ab6e079d78374486fbf5a4c36345a78a8f5a675bc29962989fd5a18231ad6e0eddcf9d8c5044876c584255b65c4552988717863ff757acbbf4e22660cce0d2ec2accefe76a214ced3dea0a4f4ff2994355adfd879e4055e98270f7dbd0e1d8e66389fde193f4fc502061da2e51f72e65872b5f9291162f05a800fb4681f72958bfb069b73dd6eea704d13ffd933403db67e705f2d73576afc7101b30d8e4c6af28f507e332f30a270b987d5d60147c2a3104076f095ad226cef61d9e75aa6adcd1065f5920b8ad4356fe7034fc19645f9dde645ac71341df89af9899c6395fb65cb090d56d353b5affa79506234f5884bbd160d35b86ae0bfa8a3b96c52d1f86a7ccb5746b818f0be4eff2c39a8e5f211e6d9b80fdd5a4fa3242460e6530331a095c105145b2e5c1cbe9040bacce561ab6f07880fe87de428318dfb65adb73793dca33e545973bbab5fd52010eed06eca7a75bb212236c6e1a8d89b78d28098a951aa3f45bebba" }, { "category": "outer_inner_identity", "entrypoint": "bundle", "expected_failure": "embedded_configuration", "id": "bundle-embedded-configuration-member", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1d766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf83be6b196e83aca2d0c05a846800f01341c8bbe0f06c9a80a56110b675488df562a60659acec0a8983166f056bc63ceae2a319b27f5c70bce65bf4bc93f0e1c739ea85201cdd924ef6844eda072df05f2fcab34b03313b20bfe716d5d33d4716881dca9ed3bda0925a31e895f804ac77b83dfcee85846dac9f0dc8611570b68810a451f4b8241d81d07eb5e5261145d0700799385ab01163571c2b98f46c9eb1ca3521b026956663cb29b4daff427562645cd52c1b82b5f93d949a3523e116e5a540a478f4bc22cea75fa259c22a53ff3c28bb4522a34f60b1150d0b4cd6dd51af4755805c1d54c50acb3e79a8e8beaf273d2d924cfef8f2a2da5e12db4acad98e87580096d630203661a233efdd9c5af0927b3171ffbd022dedca10639d7eeadd74e69dd76e9910e59878153aed69db8b998d594bb9c74e4394a6bc8235dd0370b34bb1f7508ac125bd7d5d4d4157a0e8af75098d4b7d052e47bff12d106df15c78214ba3bfcd142b2d0cd4bc17d1844bded96111916e03e1a84006f5fb09e1f47c8d85e8ad23d4faad722f318ddfb6a78dad36f7ded67a6022a04ce91b262adef5222983dc0e853e8dd6ebcc9130df63b7668fcfeb0494a5a40d98138f407bc63a7a816ced5031d1de7655933f34b1b66242f6baae86f32c276ba92e5c0d102077db7d57329ab6f3363f6f3dd4dc1cd44ebeb94836ec0ad9b5c4297cfb60d005b3de87cd181621f255607d6da0a27ccf9eec3fb568983ec5364e525f304d1c4c6e078ca461de367fc318ff27188c897a1822bb975db748bc982939034a1cb41414145b858cceca6486b9c8f58bc7eef43757ecca7861e28017bd7aea4632edd7c77321f7f1111b91358ede5ef7bcbbf47668deac20b9def81226eef4c95cd049c625849d5ba21c8c46fe9b8ad69541943f38be7682fa6a7fed9db9269ec3b111d4d62a4b7a73226e841166eda6f4b4b54ca84191ea7380995e3607b452c26122ec368269448323ccc062296076bb96cb50d3cc688f0093e103a2b0af7bc920441393f1649bd49fe5f83c4bbdbcfd2ddcf8b378b98d97f976fd694feecdcf717f423d0ea0e1b566ba4211f517246b7ac4f2e91ecd869c56bb2c7828c46adbccee58f125eeb72651551bbaaba21a5fd6c0b09e437ab037329f07eceecf5e2933909ba4fd864dbede0590e9f6978519125941be20c677cb6073c74f2d49b19b608719ad5e20263252a57c54744f1aa74befe6dc095bd4628668ad865eda1e6c9806fea5b953eeaf16d86aec4ffa7973337c559d5a3f363de26a9d8bb9b3c6a8820c32d63aac6ea9e75627a6154f5b6e78c1410f7f78b7b0605720e36562748acf4144e6e5f01bbf3b1469861bd6f48bc444ac9725cdc8cf627c2b9f46f9ba8761356a58e8e0fa867420ca6823d623a17b778fb185c44665a01fb25032edc6253c58a014842e38cefdf9ada3a834352ac8961178a0dfab10303d44c74c660768d220a1c70098742cd2d944653d4b1a47d0419703cf9483742959dcbb4bb1594ce48c432c916372d0d9cbf7c611a8475c090be16a2ed8fa7a121b2ace9cedc7ee2245f71328773b6cd4f7e3de414e31371506e081b2139eeffd680fe9ca5945d6af67f8d002d03aa7f1ec568b57023ee847418d208bd3d86b3a68d5b3566a05ecd5752860b56acc8f870f75eac9d5cc16ad568da446c36da80a585f797b6ee372a778176677118be43498e236d150eada6d38747c6d72fc510332e25d406e116a316328561492ed5fed014be60d45703baa79cd329fc865e36a7a5bffe38f0595a8c87f8ec0a54e2951562a0b9817ce81218efdd1913ba7451e77ae1313e844374b1f1c29080cf342e13f5be02f694d0f9c78ee25b0a924368d73ca2ef8360e5393fb1f211f73c2981207d23aa9460e16a6043bea6a2362f0aa4843de3c6a5e0063134fc975b2716d97c8a4824b0303f052c3b26e499d7aed0cd8e145516a2020c1228884365226004fd65887f2e92c42f60b79b73e88227d4994d555c1068fe5e1b2f34e0566d0a84f58f3262219755e657d283a22b546aff12f54a10760186fe61b297ef7c09bfc79c2d513f848b759793cb4fd51273e01efcdb55ad7dbe36d911eaf2b15ef2a4c2c246271d3a14f0ccb42a073019c272c99d69b47af957549c82937cc1fb887602e54f6c65c473270ec602ffb828b74c42cd838ff1e1b433da5045a742d8535d5cb2117d3ab675b4345abad5f952d3d1928d3c83c798d4483102ade7f22438df5daaa8f998bb605d5e1064dab130031dbc924426e04d3bdf1aea9345fd5e3fac6f71df4111ee61d7f3635316d3a2023d3428e7441598d5baa63583a7eadaf219b5ef0baf1e67d2adef6dbb72338e61dbf746117ad0075c838b99e55d30faeeef4736667f413187a6846093311e7796d5e12dad7b52fa9f3ab2c7dbfb626555b7ee1dc09bb89705b8b52da06738c84bf4cf747dc832ec223f09dc825a1ba87e362ca370f1eec76136436e61b07cf6541a4c4b0e5fc98633474d67e075f487b4f7a27d313fc28481bbc928e8e3f37972d4a26aa7769384a0865274ccaf08d973ed8aff56ad470d5bde5aa43fd0952c85588a16660736e9d5a66af9ef9ea8b0f0e7c5990b3f3d1d93022499171d91642ee8196856b50405c7f3902839c373632be1a305ebd6b3b6dc439299508e034c9c0340b56b562a59dd9949f396fff0756b9271c787df101bb1f2409d9eb4cad8736e24777622f2d2eb70e62bc94ecc2b78d43cbe37541ada90f464f4e954c8c126264926e07240a2084c073ccfc7077ed9e65c8e6db9e003593ee2499cc458b2817642123e5e1271b1743faa02275c7e1656d9e9c89dd55e20472b363b13ef337d371d695e984249faf07934b959ce6257c9a7fc56df0e55fa02d7d782188911ec6914efda56cac32e033de0f184f6a5c6072f39745b2850d596210807e55b3dd1792d960f6fff08caa0e0575ec7de16d5007b8a81672278be25bd9c70cc57f4cdb65b6d3630c6859ed983de895c93d7e732310b70f022ff38bd1d59b3521ba55da614b267161cab414d28ef55122807c001e8c44f995ea8e00e02fc1f2f0b52cb6f4b6f43c9667fe348cd8ee1d27be7791f50154240ea67249ea1f8bc5af911289b75a3c17b6d44a685cb2ca53977829addb1e27d03eebd50945be1733cb50e875ef53ab9e07148ce57586dc796366ec14636733e98111a2b0d438a8c23eb4a997416296cec4da955544ae432a85fcc44e6bc3d4f2541af7453d1ad7d5ed5fe5662729dc2cc630f5cc122391c57bbf5bded0c8fff15fe9fc23bf12c7fcc6df7b6ba29bf961895db59ca67f0537637f040a4436e9b56c9c00aeb9fb266fa1febe8d3e55dc7697558bba98a275754eb750f8f1f883b60a5894681f60c6b6dee73b984a703e2f41f49011419cde985e2e733367b5fd8b50b15eccf4a4ea19b335d99d3eee7fc0fe01b361dc514334b3d65d44a062fa92fb019b5bfa076877810f20f7107ee0d6747a64c31d5cb66d9c36b5f785f83eda4b5cdd5ae32188254cd40d062a057efb990b46fa1c8cb34c9c1046d42c05eccd2f01c3cbb26239e2cb97bbb97928714e76e3d946c8ea66588f1045cde523e0ca41f77a68a690516ac30d09361db7e571ec3a7c96252c5a726f0e830218778adf89acf00372b56d37b739e641f5d0fc0cf5594d66c85e93eb47dd3770cab21e05d6b89b86f0f15f391d16ed87030e13c213a34869a61eb0758bccb679d0815887d341effce9c91a5d23af69cea0d438dbb2a5e1d6daf4a3a059a10b35139a6233d9aac71521ddeba23891afb0ed6cc9649fe9f0a4bcd8f37db5b39c3e1f6c2ba347f791ac2eb4401014f2eb78826d8b960a1cd39686ac8f3de9d60a4da34bb8eb249ef463dbfccbd8496c23851aee9b070bf54ad5809de70e04d6c059eb36f7e28aa60e56883e6c852ef5d605f56af4ac85cc4259f37421339c6b5042a31176cb33dab9846bb51bbb5f23b7af1944e5d76845a787f121cd6e1f0354b1a79120bab5db1349166b99e1776ba7b6bdfc11974ab153123aba67a2bd1c99dc2d02d0751b738fbad99e5eaaaffa8dec431abafcc32fd7f2513090dc6674a6ff1d4dc02ad198a0cd0392f8044a72bd626defca1bf44698eecd20b49adce6b0374918607e9ee13fe5ec42a3a2eaf710ef1479da77d939a6f1b3d9da6af056cc74252f303b35bad25e5fe24d02b1f3a4e6057b1ef80ce1760648b01532a0af2204dc34eff2080043a661d1b52794723c2372d7d698312ec49daa4699826d348dbf0b45" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1d766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf83be6b196e83aca2d0c05a846800f01341c8bbe0f06c9a80a56110b675488df562a60659acec0a8983166f056bc63ceae2a319b27f5c70bce65bf4bc93f0e1c739ea85201cdd924ef6844eda072df05f2fcab34b03313b20bfe716d5d33d4716881dca9ed3bda0925a31e895f804ac77b83dfcee85846dac9f0dc8611570b68810a451f4b8241d81d07eb5e5261145d0700799385ab01163571c2b98f46c9eb1ca3521b026956663cb29b4daff427562645cd52c1b82b5f93d949a3523e116e5a540a478f4bc22cea75fa259c22a53ff3c28bb4522a34f60b1150d0b4cd6dd51af4755805c1d54c50acb3e79a8e8beaf273d2d924cfef8f2a2da5e12db4acad98e87580096d630203661a233efdd9c5af0927b3171ffbd022dedca10639d7eeadd74e69dd76e9910e59878153aed69db8b998d594bb9c74e4394a6bc8235dd0370b34bb1f7508ac125bd7d5d4d4157a0e8af75098d4b7d052e47bff12d106df15c78214ba3bfcd142b2d0cd4bc17d1844bded96111916e03e1a84006f5fb09e1f47c8d85e8ad23d4faad722f318ddfb6a78dad36f7ded67a6022a04ce91b262adef5222983dc0e853e8dd6ebcc9130df63b7668fcfeb0494a5a40d98138f407bc63a7a816ced5031d1de7655933f34b1b66242f6baae86f32c276ba92e5c0d102077db7d57329ab6f3363f6f3dd4dc1cd44ebeb94836ec0ad9b5c4297cfb60d005b3de87cd181621f255607d6da0a27ccf9eec3fb568983ec5364e525f304d1c4c6e078ca461de367fc318ff27188c897a1822bb975db748bc982939034a1cb41414145b858cceca6486b9c8f58bc7eef43757ecca7861e28017bd7aea4632edd7c77321f7f1111b91358ede5ef7bcbbf47668deac20b9def81226eef4c95cd049c625849d5ba21c8c46fe9b8ad69541943f38be7682fa6a7fed9db9269ec3b111d4d62a4b7a73226e841166eda6f4b4b54ca84191ea7380995e3607b452c26122ec368269448323ccc062296076bb96cb50d3cc688f0093e103a2b0af7bc920441393f1649bd49fe5f83c4bbdbcfd2ddcf8b378b98d97f976fd694feecdcf717f423d0ea0e1b566ba4211f517246b7ac4f2e91ecd869c56bb2c7828c46adbccee58f125eeb72651551bbaaba21a5fd6c0b09e437ab037329f07eceecf5e2933909ba4fd864dbede0590e9f6978519125941be20c677cb6073c74f2d49b19b608719ad5e20263252a57c54744f1aa74befe6dc095bd4628668ad865eda1e6c9806fea5b953eeaf16d86aec4ffa7973337c559d5a3f363de26a9d8bb9b3c6a8820c32d63aac6ea9e75627a6154f5b6e78c1410f7f78b7b0605720e36562748acf4144e6e5f01bbf3b1469861bd6f48bc444ac9725cdc8cf627c2b9f46f9ba8761356a58e8e0fa867420ca6823d623a17b778fb185c44665a01fb25032edc6253c58a014842e38cefdf9ada3a834352ac8961178a0dfab10303d44c74c660768d220a1c70098742cd2d944653d4b1a47d0419703cf9483742959dcbb4bb1594ce48c432c916372d0d9cbf7c611a8475c090be16a2ed8fa7a121b2ace9cedc7ee2245f71328773b6cd4f7e3de414e31371506e081b2139eeffd680fe9ca5945d6af67f8d002d03aa7f1ec568b57023ee847418d208bd3d86b3a68d5b3566a05ecd5752860b56acc8f870f75eac9d5cc16ad568da446c36da80a585f797b6ee372a778176677118be43498e236d150eada6d38747c6d72fc510332e25d406e116a316328561492ed5fed014be60d45703baa79cd329fc865e36a7a5bffe38f0595a8c87f8ec0a54e2951562a0b9817ce81218efdd1913ba7451e77ae1313e844374b1f1c29080cf342e13f5be02f694d0f9c78ee25b0a924368d73ca2ef8360e5393fb1f211f73c2981207d23aa9460e16a6043bea6a2362f0aa4843de3c6a5e0063134fc975b2716d97c8a4824b0303f052c3b26e499d7aed0cd8e145516a2020c1228884365226004fd65887f2e92c42f60b79b73e88227d4994d555c1068fe5e1b2f34e0566d0a84f58f3262219755e657d283a22b546aff12f54a10760186fe61b297ef7c09bfc79c2d513f848b759793cb4fd51273e01efcdb55ad7dbe36d911eaf2b15ef2a4c2c246271d3a14f0ccb42a073019c272c99d69b47af957549c82937cc1fb887602e54f6c65c473270ec602ffb828b74c42cd838ff1e1b433da5045a742d8535d5cb2117d3ab675b4345abad5f952d3d1928d3c83c798d4483102ade7f22438df5daaa8f998bb605d5e1064dab130031dbc924426e04d3bdf1aea9345fd5e3fac6f71df4111ee61d7f3635316d3a2023d3428e7441598d5baa63583a7eadaf219b5ef0baf1e67d2adef6dbb72338e61dbf746117ad0075c838b99e55d30faeeef4736667f413187a6846093311e7796d5e12dad7b52fa9f3ab2c7dbfb626555b7ee1dc09bb89705b8b52da06738c84bf4cf747dc832ec223f09dc825a1ba87e362ca370f1eec76136436e61b07cf6541a4c4b0e5fc98633474d67e075f487b4f7a27d313fc28481bbc928e8e3f37972d4a26aa7769384a0865274ccaf08d973ed8aff56ad470d5bde5aa43fd0952c85588a16660736e9d5a66af9ef9ea8b0f0e7c5990b3f3d1d93022499171d91642ee8196856b50405c7f3902839c373632be1a305ebd6b3b6dc439299508e034c9c0340b56b562a59dd9949f396fff0756b9271c787df101bb1f2409d9eb4cad8736e24777622f2d2eb70e62bc94ecc2b78d43cbe37541ada90f464f4e954c8e0679799e64622a047682d37cd1e66d6aeb9f7899febabe597187f326d6d45de02c0a7b3c25f4e041151913eda43069c6ad7d75ff9088d55aec1675a076ac38f226df6bd89ce6912598b81d9a588287e92a709b19cb63a6f85ba86a653978c89c1eddba62ebb36cb634e102e8025e57721c6d72e8ce1ea1831a427f408e701ab1dc1689c261f1f5cca7b70a0d4fdd72ee6c4c56a0b005732799ec558cd101c54c67c661bee9490d7f65df9c38e890d36c666b3011b71d5d7efdddca98832d40b654d26e453e7872ddaf1bdd82f4377dd12b595a9859fbdaf29661ec2ec9fdfead25a5e3adfb339a66983a8288f91935a276dded1935280fbb6442f01486d2bc8e1e9bbf4a2302bddf4b0e52bc9c4e93706eb59a4767dd3ef0fe2553a81729cd51d943e215b38647198cfe2cddcf7f747df1446d697ceb8010b9abd53fa2fe15a9ad9f7b5399c1c5c6c44d5cbe412a97f2ca1b7dced4c17f07f34d0730d6c2d16de163272cc67dde28e5ce1231ce9475e94093c8d09efd5ef4ea28a519c8e9cdd5766cabfea630de04f188bb7d4a2f7b1e080b5920e2af679315a5b3f62166c4f0b0dd2951ce6a961993a2f9ae746858bc4af3effa83b01f5c8569796ec8e0c3e333df5231676f4cf48b3a6d8ac8984428720251b8bb9348f153cceffdb10ab522c2806ee0718ffc00b27ac750443e8ffd4040a858cb9df400870ae21f7974811d2ef94568edd6475179c715669c6c8b0a87f380f83bc01a44c54be0219a7a1dda5b1d671d66989b0b40ec1c80bf5a8d1143a67354bb946b15dec9fd7a2181c997bdaf79247d5867e2dc34c6e4364f8b0259cca93bf8a943f77c7ea69c5e68d40e033313b8f97ae32b6e96332d3c7c61589e061030929ed0ecfd03699e4121a139fc47f4e1ca01b3415526c55e88b21cce3167d1af4e0bd8f79987f1ea44381a1cd1ac1e0a1bf822ac4768ba4fa86d9dc8b66bde8f0d91de41dcd7f4cd126709ae7ef292d03ddbb7bfb0cec2e2a7a04bfe5a3b45812f2bc1dad01124c090fd69c6f6f4f971cb2b87f180b3b8ddee06bbbdcc291b7e37a20bef89de3fa7460d1ef8f2698d77809a0d09c09097ade933e7801749ab0ca0aa7ddef963c0d7e7ce5f6c39831bdfad0a4ded52958d9dfc575fc5c64ef02ba7ec84e90c578925778429ffea2ee86efc96b4c34d58ef25392b8b6e5050ad1f23dd3eda8aaf76b113817522a0932b40e0d76d5ff69fe936c86e0d5c05bff18a6da2adab1452166c9ee81234f690b3b816884bfc08540d8b76989c20b18b6648fd631f7195b195c719dad089e8876c73dcc6fa1cfbc57606abce4e53aac32769bd6b9a80a5c91592f85d5920e23d2992955ce15a83ecc20bffc4dcacf327521e773698f771e3e643e4efbaf917ac1978db7ddc3fd4f3eb969317ee45cd7f236e6f6b24b0994c1ea20610beeb97cf146000f43cfa205f35ea5e72eeb63d479270b2b055125bf92c9da53ad9217a514effa732ac5e8f4cd3bb8388d6a97507e39ac70bb49586aaba55f2454573d900f3ec79ae5b66e7c785a48c72a74aefc2834afa2050d6b4c8bfe4d130168ef82b57eb02eab5405b79892a1a2747f76d4ad95ae6f6ce92c0409a8aa15683b7433086ed88f80fdee6d32ebbb2117e1eaa1aaef8760e245544a59ec6d69aab08a7151b5f49ec40926c330da5bf4ceaba929e5c908d5fceb4793b2e2bee00bdfcb6f67b272fc117fffd65cfec98b0d24cdc2966a451fa5357f8a6721c" }, { "category": "outer_inner_identity", "entrypoint": "bundle", "expected_failure": "inner_configuration_digest", "id": "bundle-inner-configuration-digest", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1e766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfb1a9c24c9e3978edec3b0de59ff2508ba52a8ffaff65fd7fe81db186e333af5ffe5c7bbd2ec5c031a539ec3e26e76c3bce7e04f3be37c33d5ab190801f683ee3d8a93686dcc3700e8b12619d19f815df83932ffb89c1ee740e3a793512d82ed65cba7071ec753c8e902b9ba27bcdcbe9510adb6a9e5c88fb35c993f5dc0da75b957e13d4274e63d0ae69b7b548bdffe1a0ba175266afbc28d0e879032a78dd8bfdb94c739141f0c68f0ad1bf13a868b4d660712988b37d28035287df065a43436e65a22b8acfb90588ee8e02ae795a9a7a942b9aaa27fee9f3114be66e1a26bc7e3b7f96bec954e4557826632fb206463e994e55308cabc38ce222a2a7356da440f65cd98ccdf72ca8ba9c601e14881973b93ae5e34a7e8ce669685d46a777e3b51cd83f679d4766f037e96f33cccb36f70f6400a9e3e0d6287afe35dfac600ee98703492f42faa80e1762a4e48b3dea8fad491cd2fa0a3b96ce7095259cb303bd98649278ddc0787465399ec16c83fcfd7ac0c56196c9b44bd0bf55799a5033200d65d73d853797e201bf293add5b9c604774c81e0f070325b5bc8056e4d1b2f9d1140f26aeb4a268a077201db50e529f220c00a35080b6ecea81ba6f1ce84aacbb18505dc0a70664c4251017ab5364aba6abb7e614bd1929d8ef1b57a4da880f1d615ad3b521e77af7a78b8c36df518c3c3c56eae306929ac94a5c62e2958c44d73b1dcc0caf464af8efa14cda0c33e61c0d1f7f290bd734a6bd8f6bce23bfac730130d84582271befd9258787795f66e0a1e34837bf9cd63790cd29d025d5db2ba4b85c156baf2ecd6ceaab2e8dafc5be680d6b9fe083be0d07a31acbe22aa93a65d4c8245a15ac7e62d6d9599d9482de458f319f57aa23fb6d4b5ebd0230da84a4458138f5b296ee6a6fe868df0031379e9d579b0a0f2feb0f17ba757fca66e0824c9215454ff3e45aeb03879db899be221e521d1d56c6a3d760397d3a6f53ed76e4dd5693dd4367d1015b420fbe0076d2d21f4addfa449a10286497430d1a5a6cd9ca4ca51c6504e00cbdcc2870da6ce4e5c16e2fa688fe95218f45f620a1cee3e12bb1a10a777ab06167d0ae1c047ab76f8dfddc62c5bf2de730287d0218ddedb3f76edab0ff4327f7e3c6ff85c73867b29011e75a167402bc6fde64d6c8cb0cae0feb598edf082d868dc07e7a7d54e1d81c9bc05de33e69e6dc9a212b38681430d79733669115cf7ffe389c2926a4031c49bddce6526c125663f1443daeee51377e5f50c890f886eec3deb8dd36b95e080d1f5158ee03d35d41092e79dbf63ff31e6312e80e11d83f17d3f49f1ffe74de6bf3ec4b4b8922108240ec3249f9282a9bb7d208c292fe5a0aef6a10d7c6ef2bcd7ae14110a51898998022a7472a5ededbdbd720765d9b76b15a0549c1910ceab80b124bc5623e00337de0ecbe50453474b9c31c66574d1f50ad89a37bc48e6d3afd59844f306dc54a69a5be1b13f8cbc0b9732d730462637e2d6f876cfa6a116e0c2d387c1565a0f027dc76f5db582c05e3b230a6a7d64478ed1223da2136ff026030d2298c7499c10db97c5ec33dcacaa5aad6480d99dfe0c3569d21fddf992726ca036130c4715f9b051e612b99135613b22f756c2067b8acb2bb67f6288944babfdb2672ebebcbaa960a05a880a42a64917934b2ad2cbaa31d72e6c7584619900eec05eae42ca68982c309c74d498800621ad4081ffb94c8e555f959d48e4913c643223e656f41480533b5f56ed038e346cf06fefc121ccc7d3c4e0d5288374182ec28866df18bf4cf29fdf4840c06062afeb8587f204ec23a41bf2fb5b607b664ac80c9b374a4a476e288bd30d6dbbe46f4180301e91b691045f159289c4632d834166596edd4010a74fe4032fcc7e665ec1c1a21017dfaa247993dd47a97532278c9ac98bef4635915e196ba87c9f11eff77f80e45d6aef5196dc7fb9e835de43d7b5508239ea7c166fcfc61b66d66433f10696aae7b82287a5eb35fb1ef1cdcbb2bdcc1cb052a2ebed165325b874d7c6620afc33730b40ac34decb6242818bec445e93ed31c2ffc2aed904ea52b6ae16406e74cff51fc65eb7c680b82c50484c44c794e9bf6a6582babed8622d27e29e8beeb6d6be3e49f9293781b05f84d760d1c65da7b880785110c4af0004a4c2e436bd6affe8381695e48e88447ac4a43a78aa10168eb52ba63e646937634e082c31cfb59b2f1fb02c67b3bd4ebba9fd340d361f9b13bf57fca756ae61d269a9be287e73dfa1271cd19f1b8d0262fa3612beb655ca26f94b57c2be0d8c9d8f16997cd2b0f7802de77fd76d0850715810d3d10ff29c7f718f2c5158c947bf7f182c87d07c4eb5ffc280435fa38fcde806cb25414351ea829eee08ff2560c57ece4badc5e275f36696ec6feef41960bcf4f7cfd34234c92ca5ae74b8e82603263836d832cb081383261a11429f599848c760339d2917c5ba326a1e53682a92d6e8037e1c23af56dcabf899438f91278fbc62a1dd624d04791c2d5f3bf34c77ee80ca8714c9fbacb7ded92deb4eec15173368432515594658aaec3de118e831f1329a41a8786d36ab526f1d34253eee28879bf066e527fc331adf63cf284c1411359dc4e3cbd9cf9af1f2b51bb9a9efa97b30fd5fe1f0f5244c1715e24fc18554d608e11864702cf27d7c4541f9d39b4f62d1c85afc3517b90be3eacf7c1cd6711be9fbae5bc4de8d781062f18065fd8bb3e4b14c66d3b50e4d15681e765226f44f99a79995d0ac5b68510dc0219daa92474ac732e67210b5b387301977d7126a9aed9be7103c4ca35221f24220500eecefc92e22ba26e0f56ff5eed8b3677d6db58991af78a7734e292e21a09d50ed58d5d420b46e16f4c117030fd2acbb9d1f0fa53ec100b9a29f0773f56ec9a35b895e3e2ca1ab892efcc5f5d05e14827eb349e9fdc0dc4c9dc1ae39d9ed9693c449ff2b58ead8b662cc3153faf0f8c4e582ffb85e5057fe26a71c4e9d4d6ea3996f5aec891202bc640902077517986eb612fe614e0c2e1daf63f2ac24fd286695308c621d049b492db54908a3d22adf6b40ae5e342debb19d330fb52a03afdcda073eed1b983bfced32fce8e9d2748ddbcd2c472be1d8afe9e92eb3228b06e60a8d536e5b4e74871a790a11db99d1dd1901c2823ab2c44f0f74cf2033679f53a5016b1eb4bb27a2a1030004bf2a426921e75b27df868d30840eef475e65f1dc0830c952f00353fd4ceda887fe3a2932b30df7be23c11e83fb6f7cf2b9766966e19f4ef1625351610283d6f4ae9ed252957f7632ba6d653475ef3dff4e1dd1f232dd99f88d3a190ebedbe28d9efa90e20c2ea0fc1b4259da2c0eddae73d6f3f37664399e73b91b9356828c3b27010f8e27158327ca215243a3fface151ef51e4853d6e50be5c56dd70b72198d968fc54ffe76688406554b30ec32c45e52334e03e976feb70036fb1e049fe226bd1c0c0728fd6f4c53b9b9e4a260a2a565a61dd795d39bd1e413df9ba6a7be56f3dd244deb7f7c8093e585b715f669ac88ed28efb2d60b219a00c42e07109638dcf5bf9842fd55043fb8d0a8af41200aee34073f6fe130c1a258caa21c99b5e9dbbade6bb697d10bbddbef2cf8e1409f540aae9ab33b3fc340974a5bd7b0b6bd3d447b5bee133e5f9d0672fa984b288f34d7030d854fc568a636513807bb92c6a2cd8ea704170905633ee8f9f9ad65adb38ad4fff358a191424331b271475a1a5ec875a96dcd27357a6aa7f685896ed47e45fcb3ec321cc773dc2497f39f406e5d378079ad976ea23380b49cef1f3c35c37d31bcde8468fd48746e3097130f07cda9339147867acd1f41860e2451beb8a59e23c0ad070ccad6be04a3a863baa04078be40d968f21b74d5c40e0560975809a9e88c4d912d7dcf5287664d714308b17127277348fe3f0866610bb302310a79a1ebfc735654faa311282e3eb0ee7b79e22c9a7271c6f789622fc0b6ea29af622b7f0f8d92fc211412ae21b53947fee2f5300ab811690ffb49abe86031f377865bc51553fde9614cc7c2954db3ce9cad98b5a70eea8866e0ff55bd2918420214a0270c0646d6401016716d9587d1dc3b914377e22a3ad8d1497ca643582c2084ad0b2e2e4bb0d1a20ba696d2ec9b38d1ea5aa87d5e5ac80efbebafc2d044013a4dbf4657b2deabc4101ee76c8404e5c3364eae3895138c46086ea7669e06830424677018c58abad94ccf966b63b83b3a355b856302a97732df9a5e9abb04ad0e192c906a38" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1e766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfb1a9c24c9e3978edec3b0de59ff2508ba52a8ffaff65fd7fe81db186e333af5ffe5c7bbd2ec5c031a539ec3e26e76c3bce7e04f3be37c33d5ab190801f683ee3d8a93686dcc3700e8b12619d19f815df83932ffb89c1ee740e3a793512d82ed65cba7071ec753c8e902b9ba27bcdcbe9510adb6a9e5c88fb35c993f5dc0da75b957e13d4274e63d0ae69b7b548bdffe1a0ba175266afbc28d0e879032a78dd8bfdb94c739141f0c68f0ad1bf13a868b4d660712988b37d28035287df065a43436e65a22b8acfb90588ee8e02ae795a9a7a942b9aaa27fee9f3114be66e1a26bc7e3b7f96bec954e4557826632fb206463e994e55308cabc38ce222a2a7356da440f65cd98ccdf72ca8ba9c601e14881973b93ae5e34a7e8ce669685d46a777e3b51cd83f679d4766f037e96f33cccb36f70f6400a9e3e0d6287afe35dfac600ee98703492f42faa80e1762a4e48b3dea8fad491cd2fa0a3b96ce7095259cb303bd98649278ddc0787465399ec16c83fcfd7ac0c56196c9b44bd0bf55799a5033200d65d73d853797e201bf293add5b9c604774c81e0f070325b5bc8056e4d1b2f9d1140f26aeb4a268a077201db50e529f220c00a35080b6ecea81ba6f1ce84aacbb18505dc0a70664c4251017ab5364aba6abb7e614bd1929d8ef1b57a4da880f1d615ad3b521e77af7a78b8c36df518c3c3c56eae306929ac94a5c62e2958c44d73b1dcc0caf464af8efa14cda0c33e61c0d1f7f290bd734a6bd8f6bce23bfac730130d84582271befd9258787795f66e0a1e34837bf9cd63790cd29d025d5db2ba4b85c156baf2ecd6ceaab2e8dafc5be680d6b9fe083be0d07a31acbe22aa93a65d4c8245a15ac7e62d6d9599d9482de458f319f57aa23fb6d4b5ebd0230da84a4458138f5b296ee6a6fe868df0031379e9d579b0a0f2feb0f17ba757fca66e0824c9215454ff3e45aeb03879db899be221e521d1d56c6a3d760397d3a6f53ed76e4dd5693dd4367d1015b420fbe0076d2d21f4addfa449a10286497430d1a5a6cd9ca4ca51c6504e00cbdcc2870da6ce4e5c16e2fa688fe95218f45f620a1cee3e12bb1a10a777ab06167d0ae1c047ab76f8dfddc62c5bf2de730287d0218ddedb3f76edab0ff4327f7e3c6ff85c73867b29011e75a167402bc6fde64d6c8cb0cae0feb598edf082d868dc07e7a7d54e1d81c9bc05de33e69e6dc9a212b38681430d79733669115cf7ffe389c2926a4031c49bddce6526c125663f1443daeee51377e5f50c890f886eec3deb8dd36b95e080d1f5158ee03d35d41092e79dbf63ff31e6312e80e11d83f17d3f49f1ffe74de6bf3ec4b4b8922108240ec3249f9282a9bb7d208c292fe5a0aef6a10d7c6ef2bcd7ae14110a51898998022a7472a5ededbdbd720765d9b76b15a0549c1910ceab80b124bc5623e00337de0ecbe50453474b9c31c66574d1f50ad89a37bc48e6d3afd59844f306dc54a69a5be1b13f8cbc0b9732d730462637e2d6f876cfa6a116e0c2d387c1565a0f027dc76f5db582c05e3b230a6a7d64478ed1223da2136ff026030d2298c7499c10db97c5ec33dcacaa5aad6480d99dfe0c3569d21fddf992726ca036130c4715f9b051e612b99135613b22f756c2067b8acb2bb67f6288944babfdb2672ebebcbaa960a05a880a42a64917934b2ad2cbaa31d72e6c7584619900eec05eae42ca68982c309c74d498800621ad4081ffb94c8e555f959d48e4913c643223e656f41480533b5f56ed038e346cf06fefc121ccc7d3c4e0d5288374182ec28866df18bf4cf29fdf4840c06062afeb8587f204ec23a41bf2fb5b607b664ac80c9b374a4a476e288bd30d6dbbe46f4180301e91b691045f159289c4632d834166596edd4010a74fe4032fcc7e665ec1c1a21017dfaa247993dd47a97532278c9ac98bef4635915e196ba87c9f11eff77f80e45d6aef5196dc7fb9e835de43d7b5508239ea7c166fcfc61b66d66433f10696aae7b82287a5eb35fb1ef1cdcbb2bdcc1cb052a2ebed165325b874d7c6620afc33730b40ac34decb6242818bec445e93ed31c2ffc2aed904ea52b6ae16406e74cff51fc65eb7c680b82c50484c44c794e9bf6a6582babed8622d27e29e8beeb6d6be3e49f9293781b05f84d760d1c65da7b880785110c4af0004a4c2e436bd6affe8381695e48e88447ac4a43a78aa10168eb52ba63e646937634e082c31cfb59b2f1fb02c67b3bd4ebba9fd340d361f9b13bf57fca756ae61d269a9be287e73dfa1271cd19f1b8d0262fa3612beb655ca26f94b57c2be0d8c9d8f16997cd2b0f7802de77fd76d0850715810d3d10ff29c7f718f2c5158c947bf7f182c87d07c4eb5ffc280435fa38fcde806cb25414351ea829eee08ff2560c57ece4badc5e275f36696ec6feef41960bcf4f7cfd34234c92ca5ae74b8e82603263836d832cb081383261a11429f599848c760339d2917c5ba326a1e53682a92d6e8037e1c23af56dcabf899438f91278fbc62a1dd624d04791c2d5f3bf34c77ee80ca8714c9fbacb7ded92deb4eec15173368432515594658aaec3de118e831f1329a41a8786d36ab526f1d34253eee28879bf066e527fc331adf63cf284c1411359dc4e3cbd9cf9af1f2b51bb9a9efa97b30fd5fe1f0f5244c1715e24fc18554d608e11864702cf27d7c4541f9d39b4f62d1c85afc3517b90be3eacf7c1cd6711be9fbae5bc4de8d781062f18065fd8bb3e4b14c66d3b50e4d15681e571397248f3ff77970b0cd6b9980ac11f1fdbb4756ccd537723351659776b19fc796c39b4a8c8bf170dca9a22260d38234918f6aff093ea24ac7c0845ea43eb8a277bccd551960ff68d602eeb81f50106da02d4eb534c5d5be5670b093169bd27cba2fadcec45ec0a0d9b18c67a794ef4da38b88ebab9d91caf89f29c51534ae34926f02f9f98d631efd4d812d9ac91d668201587eaa68fad996822960332af3ca7535c27c1afe41243d06e74c4ecce87f22185f4aedace7125905bdd386f307587e36e21fe1df3d5fad5f9332ba41083d130171cdb239f51acf3d7559885333aa4e5a311eaec4edfdd17dd60ec56b226fc81b86b8fddb89ea9c5c924c3998e3844cfb4c2db67b5168b98909cbd2f2cb82978e98c76e8b4fc635db186a107bf9c2ce79d5a303beb2144ebae17e1052162e86a5e18fee94ab3613111370a77d9b9229a2444bd72f974821058fef675f45113959501951c2b283bdcf4f08968df90972e30da61b32409f83db6e5907a99308d2301ec8e1a2428001b3236605fe2e7282e5e923c7af18f175343f190ecf9bc132230cf9492d8ae85e0e7b32fd08aa70070d5ee1dddb56985baa1e1dbfa2b64253c784199e12e95a8340e26cde46d14f0a569196b3caf153f1113ecd81504f31f7965dba313fd8560d7102b429e9098de52ae7866cb04544a2bf733c3546e185307e14ccfb80f37e74f1c87f325bd0e02097deb624c6092a4e0aa5a88a4729a2fd390d39ecbb50bc78aa5a7ac09a2d37256a66764e391e583a115fa65ba99ec2d9ded875c78de14d92c48088e5bdef5b98e42f159122eb9d5daa14f701dea261b3e23f928a2a058ccb41c95baebccb9d46eb898cd00b2cafd2ceee026915a5cb39ebb7c278219d7475bcc9b9aab2b44615def220852db1e6aba954b33d66fc4051a9e529566a879533906a089c7a5c7b28c1913013f5231e7f8e5fc7db5a58ed4edfd56f4874f4302996c43522074c962955fc922357f70f6ee9d9f6ad46c1aadbdba2951df6bac3393f6823b310c60d93db98a6ced2b98c48beb1a214ecd7361abda9674fc046c76428600091ac7a32a80489c71c1185495083550d8b6abc83ec4a540148b8ffe09a3b34896b65678a446d859c41632cddc4e08608c0152baee9b568c7d73c11d85674c6a5809b67b1b0c6e4cf60539696e0aaf5329126ea4ebee7d5801ecae111b0523b4e64153e33ba64075c3f78c787ed8aefd2caf70742e01db89b1390c62a93ab53e40f787aa612cb6556e10fa04f0ae5700e24da767ed4218b7c47748c5d89e01ea89ecd2ebd0cc2183ee5cd9cc798a0e2e79103c872b303c62ab015b0e74698387d18528c31c28a657659fc45567a44c73677e84e7483e354da799a41de890d8eddc7581e45deb705f5bc20bfd99ad9a9f0d7c245ebe4d51f381fbd51a55f52dc44ff7cc2e7d87298e0d80761338fe1bc44dc34a3d78335681d7f8845ed7c9262fa82677661df75f2ac2e5cfe85bbad5d2924d4092df78368a1d4517c2506a17e8fcdb1ebf5e1e6717abb5e3a4740373c407961ce6d838358ed538cbff4ed29ce77205aad8cc7a62724c03da55ad31765d6d4a3b8f99cec13dff5fb9b982b7db88508dd6c725f8e9e663740c704dede22d1cd0406f32ee6d9ababad71f1b498eb4a948c4452262dba8a892607cd72995419c6647cb8198200be55d10a851c1d0c27801f994f135516875bcdb10890f457e987d9b7bec455290eb1f889c49da17e8" }, { "category": "signature_input", "entrypoint": "bundle", "expected_failure": "embedded_signature", "id": "bundle-invalid-embedded-signature", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1f766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf1a0c97be426c97f63ffc2adcf8f881e1edc102c7e5c4fb470191dc898081e2ece826bb2d6271b28c6c966bfabb09d47826f8e92d4da19cc4ef659182f69631604d216a3a795c1d1e7fce14414c4e57df1259aab00423f9091ad353d6f7e33024bab797fc7fff9cde3a6ff1ea53f3bc18fa27b1bd808ab123c1c0d440f41f405c8db42b2781e0b725146c90c6b3ffd8afd6d5403ff5388d3a3504b7906595b7c943ec5fd8239bc20f04ca20ad6e5c223da64d24763105c780336d02d671978659c010560d618b56756ed7949ebf85fc5ceedf93521c0b70cf015d264d3d1b617062cedc1223908c37203d5f12b2a2e44f4e99ba15bc9ba94abf8f1a20045ae445cc9d9f2b6765481c727f9c792fc908b0c6c97bb81acdaa7a4366d7fc30adbab3a8e696ab28492963b5046e2c8b21d7c486b7c1c3fa0385297aad4ac20faf5bca56b369e4cdc5279b1f34ac4037a700e4bab265375a01b9e3c6390d8177ef77c5c21842a6c7845a9fb8c34f4aab7a04120f5fa79ca207cca1af8d3ac8bd81c41702778f108a24d15f6fb411bb64e6d54924a519c665296d277dfc51bb5604d9c89404b89823310ad11453aba37236bd4336b801f6cd31ca90c7368012c4395747ae32192e5ea0ae8db8096af17c36373b06d83dbec4650a958ed6b49528d0e6eaede202471547da23f7f2ad29767e7cf2e4fc52ea25548e6ea9375339bd832d80b99fc5dec486dbc33cd12b8483795a1b0413959bf7a0a69ac1f3fc46c7bf86e947459a080226537798c4473bb85c8eb2d4c2aa50318c68165a1f79993e9ec381abd33c9c7bcc2543ccfd6c56db15d40167912c679f5e6abf496dac67b1f7568ec2553be95f872dcf609d5d14a5859829c9213fb6ce4aa6d3bfe8bcc6e16b12bf5f7c383a8965e9d055f5716d297fe2177edb226cc9076e95ccc9c436961fb0dc1f1a649ce04400e32a74c5a4b65f55a302bfc96a057fb9a10a41425916967a863155ac8e3b98b5feefccfe0691968ac8e08b723fb279e4755cd3fcbc188df1d2038084f3054ccd7f8e303ddce3368a3d93e1d282f37eebf63b919aa52458ea4a83d82895532a4b99f14bf442e88405f95dfa39ec0c9c2ef876cafdc17f60a26c805e214ec26a81c0357613e04de6745f810d5dd06fe13728854833d31db65b5b10be53da251e8b70e377f5e1e427f12e00e1cf207de971874ef6d21965a7c299e10fbc15e052f87b0d9c5db0cdf657edc07232a583d91f3529039ba43bbd36ed69ab805c160108071a1ad457e906da04054ad1147af16e6bcd77868669ad8641ee2b7b483dda8d3b1618189a9bae4c9493fdc30f36f070d6657aa7f8b22d0b5c362025a5d01fb61e37e883bd452cc022b5fd5bf10ed73b8eb21e448319e101a3396bf34bd2f1983a446b0e2e14bede5bafaca0cbb63be4967d6f012035837552c1f04f66e5e64e795ee1249b85a0e7a5b6ecdb322690da05065655d895110a6424f492d72fe2fa40d7f76b529006005f970d8411712aa428645b0faa2425bff06cfdfc2821e8f27bb44bad0e86c879ac0b8b39835e6b82a39ed43896c561d2351b7632eaeb5aac9dff25c6ef7800feef3778da7b8386645e0b09de222f24cad77477cf9db5995f192551fcce21a4b01732fc4928a935e11aca78a3211bd5bf62c9d658523b1086e20405e8666aeea8182d9060b605ffc36c6d0133d49dab228ffd7a661a630c9bf133ec0024e33acdac4e8c0908ed4ca683aa133d658968f4006e8b02fb74721ab50964a710d34caa978533c3231d91d0306340d96761a92b4054cf124e213bc434ca06c7d88d3bb84dbbedc84ac1563497f0091a1deb7a49a9ce95f1ae7d10873b4338233ba93d176dd6dd5adc1a483bdb2fd8ddc133a92fb9181575cabc14843f32e3c1f7b0f7cbe52bf6d44be775c92dd2b5d135def99fc5f1aab57d50317d7144948a66a115f788d45699220ada1e409db45f2ff0aeb78e08739d5b964505bbc8040ee454bc118d8afa08404d7571912b3ecc93e8f51981895d03920b152a21780ce7d88a899794e9f3715c8fea88aa083c2b0b7a5f9cbd7b37883fa0c1d6237dc9674c8716c634df473ebbcba7ad071793de46409cac83519e2fb6286fe48f1180ce02f5b6c98b172292e97d0e457775a17d757db7c6ce3f1334cbe9d3292e42abeafbb8fbe90451038f94d8e74ddee74800c7210a1a5f5617afbd78a30fce682ec3f8db3c73e64e0efcb7a50b6588a088bc6f88763b91f9852efef959876894dab362d345c87c8fb9a220fa91ee439bbd765b69beb13e79e17174bce694742e8e9fff06095613bd5af1d19e218674fcfb5572d9daf3a554e1c6e1d8bf5d7e5cbd776c2dc1da9cb772d92026b6cc0d2ab3c71fdac6c1c8cdb83833b93da346d57430676996f81f2abae44af4faf0eb31770c7ca8106f51779e05e3f4d33e2ff52bb5d98867df547cf051454c50ab5c0f71f3418b13050f03cebe5cc96450b6c7fc0c42640013a75d7a1a7522aab861add4a3daf2742ee3431dbbf6aa76d757cfcbefcadef8ff663ec4bc65b356b62ef0a13388fb275d5b2c002a0ccfc9c3556d02eb2e3838b955dca415cab1a869580e5a89f449acb5e0ee70aa813baca88dd6f0ca060a6a8b6e5e75bae3317c19d751f27232afeb47331fcddffb89a36141744df74aca40537c7568112da012e66957d9d7cbb1313ea4b76516e27d9258a05a292c09a2a813895656befcc9a4ea30f5e43e0e2e44ecc60e56e4b5819ed2f9176a56f12eb3f0573a532521e0bce71b9c5a42109064b52a9eb5c8a133cdf2431e4fe175aa6df96d2adfefda311397cb05217e06c4d4304ae47b5e8bc0911baf77eec2114b3e6e5ce9025c516c7984ece0dc0c4ae280ad897c8daf0b12b00d4142ceb721e8a8a6ef36c637a31ca613495ec4e50e7e70989be015e19df9abc012267eb2b0e4dacdc25d21957f3e90bba32dffd57e50b06d498409809bb40a57d7ba4353939bbefea336c83ccf888960efc398fe381e9536c3c0c60827fe2dc16ed1f5a891957083e0a46a38ab5897a6daa0bfa8fa4398bba36dcb3398bc5177ffc918f80af727cbaea359c8ad39edc2ba87f1f67dd36495ab9226f706e834677972d6d154612412323f01e3cf028ed966a41dc04384dce514990518b503cd4e0276d311887228d8618c35609192b5e7fd4b8ed02ec0b9f2e4b9836e8c88de75e8903a00a72e40a8f2e6cce99550a382941b9edea375382414652eb1efdd2f4db9f13bc51806472e67ce17f34c18fceee24cfc5b885cfda69feea5918cc6617c1cabafc5f9e82a3186f9830f78c55ba4573837f36602e57660ddfa864ee575d8a591cc3b971fca1b3a41242df3c0abf149d8850231e90ab5785041f7045985dbaea3c6fe5c3df14116c1bf64c78c9ed7eb6f7302f49b5bf26533cb0223f761832040410fd2f8b31a2fa2296892872e8355eb0e8474029e7ca08485cd79c1c523a10f9ef173e2d7df3ca9cff8975b87a4cb64407b566fcd74a05333e0cb9732d9380a75d8a78968e62eb615273054513f9c93ab7dc7cd9784a74744c4bf1b49b2809864abc89436a81e43e3b66c62f07f54e86c32074ff5f3b21424a315e8ad53ca482f0516199391015808ec234f2e53bc43da19212475d61adad8936ab22c5326e60e25ac316b4b7b5bdd41bcd3c20f7688c6719e5547c235fd7628b6208bb47656b24cc429aa4465cc5e1df86d8059b23539621a2a19f4cc9fd6da8fa9460c899e50a7c0f40e37b09a77d5b0833a7a5e6737f869adab50cd0ddba01b5892fe8e5e00e9f17c90cb8e0fc49056f9ede291adb986189c1519f0827451b583aa0143b3a2f19e0390d11329453ed59fc662ab4a032a674838b954ac9690fce82be2e8a1ee000370d75e139ab0fbc15282fa3cfb943509885aca7973e1addcdd33e6b0bc21f2b580fdc4ae798b4ebb74c11903f57c28e6045fbeb43ea1fecca26bd1f10df625ca896a980cc6a9ff67a475a6892bcd7d4fe4d0f8f42778899e4e726afdee4af503628973c234f812b5b181fa9f388dd032521c104839dddd2a0d59a0af9f9ce576b52e75ce02df9284fdab305ab6b991f794a47477fac11f181a597d911de22335ff73dc438835957c72e9ee72ba987750faac315602778ca1c7fc14a04c9425bf2eb6342f8a8a5522542c001605fda35a930ca155d50198dcba98b3cf3edea95cf12894b4b02b3fc4da5b68244140910f90bc875f3461dd1f36b75e1abbdd45b4e0379bf6634ffc616170" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1f766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf1a0c97be426c97f63ffc2adcf8f881e1edc102c7e5c4fb470191dc898081e2ece826bb2d6271b28c6c966bfabb09d47826f8e92d4da19cc4ef659182f69631604d216a3a795c1d1e7fce14414c4e57df1259aab00423f9091ad353d6f7e33024bab797fc7fff9cde3a6ff1ea53f3bc18fa27b1bd808ab123c1c0d440f41f405c8db42b2781e0b725146c90c6b3ffd8afd6d5403ff5388d3a3504b7906595b7c943ec5fd8239bc20f04ca20ad6e5c223da64d24763105c780336d02d671978659c010560d618b56756ed7949ebf85fc5ceedf93521c0b70cf015d264d3d1b617062cedc1223908c37203d5f12b2a2e44f4e99ba15bc9ba94abf8f1a20045ae445cc9d9f2b6765481c727f9c792fc908b0c6c97bb81acdaa7a4366d7fc30adbab3a8e696ab28492963b5046e2c8b21d7c486b7c1c3fa0385297aad4ac20faf5bca56b369e4cdc5279b1f34ac4037a700e4bab265375a01b9e3c6390d8177ef77c5c21842a6c7845a9fb8c34f4aab7a04120f5fa79ca207cca1af8d3ac8bd81c41702778f108a24d15f6fb411bb64e6d54924a519c665296d277dfc51bb5604d9c89404b89823310ad11453aba37236bd4336b801f6cd31ca90c7368012c4395747ae32192e5ea0ae8db8096af17c36373b06d83dbec4650a958ed6b49528d0e6eaede202471547da23f7f2ad29767e7cf2e4fc52ea25548e6ea9375339bd832d80b99fc5dec486dbc33cd12b8483795a1b0413959bf7a0a69ac1f3fc46c7bf86e947459a080226537798c4473bb85c8eb2d4c2aa50318c68165a1f79993e9ec381abd33c9c7bcc2543ccfd6c56db15d40167912c679f5e6abf496dac67b1f7568ec2553be95f872dcf609d5d14a5859829c9213fb6ce4aa6d3bfe8bcc6e16b12bf5f7c383a8965e9d055f5716d297fe2177edb226cc9076e95ccc9c436961fb0dc1f1a649ce04400e32a74c5a4b65f55a302bfc96a057fb9a10a41425916967a863155ac8e3b98b5feefccfe0691968ac8e08b723fb279e4755cd3fcbc188df1d2038084f3054ccd7f8e303ddce3368a3d93e1d282f37eebf63b919aa52458ea4a83d82895532a4b99f14bf442e88405f95dfa39ec0c9c2ef876cafdc17f60a26c805e214ec26a81c0357613e04de6745f810d5dd06fe13728854833d31db65b5b10be53da251e8b70e377f5e1e427f12e00e1cf207de971874ef6d21965a7c299e10fbc15e052f87b0d9c5db0cdf657edc07232a583d91f3529039ba43bbd36ed69ab805c160108071a1ad457e906da04054ad1147af16e6bcd77868669ad8641ee2b7b483dda8d3b1618189a9bae4c9493fdc30f36f070d6657aa7f8b22d0b5c362025a5d01fb61e37e883bd452cc022b5fd5bf10ed73b8eb21e448319e101a3396bf34bd2f1983a446b0e2e14bede5bafaca0cbb63be4967d6f012035837552c1f04f66e5e64e795ee1249b85a0e7a5b6ecdb322690da05065655d895110a6424f492d72fe2fa40d7f76b529006005f970d8411712aa428645b0faa2425bff06cfdfc2821e8f27bb44bad0e86c879ac0b8b39835e6b82a39ed43896c561d2351b7632eaeb5aac9dff25c6ef7800feef3778da7b8386645e0b09de222f24cad77477cf9db5995f192551fcce21a4b01732fc4928a935e11aca78a3211bd5bf62c9d658523b1086e20405e8666aeea8182d9060b605ffc36c6d0133d49dab228ffd7a661a630c9bf133ec0024e33acdac4e8c0908ed4ca683aa133d658968f4006e8b02fb74721ab50964a710d34caa978533c3231d91d0306340d96761a92b4054cf124e213bc434ca06c7d88d3bb84dbbedc84ac1563497f0091a1deb7a49a9ce95f1ae7d10873b4338233ba93d176dd6dd5adc1a483bdb2fd8ddc133a92fb9181575cabc14843f32e3c1f7b0f7cbe52bf6d44be775c92dd2b5d135def99fc5f1aab57d50317d7144948a66a115f788d45699220ada1e409db45f2ff0aeb78e08739d5b964505bbc8040ee454bc118d8afa08404d7571912b3ecc93e8f51981895d03920b152a21780ce7d88a899794e9f3715c8fea88aa083c2b0b7a5f9cbd7b37883fa0c1d6237dc9674c8716c634df473ebbcba7ad071793de46409cac83519e2fb6286fe48f1180ce02f5b6c98b172292e97d0e457775a17d757db7c6ce3f1334cbe9d3292e42abeafbb8fbe90451038f94d8e74ddee74800c7210a1a5f5617afbd78a30fce682ec3f8db3c73e64e0efcb7a50b6588a088bc6f88763b91f9852efef959876894dab362d345c87c8fb9a220fa91ee439bbd765b69beb13e79e17174bce694742e8e9fff06095613bd5af1d19e218674fcfb5572d9daf3a554e1c6e1d8bf5d7e5cbd776c2dc1da9cb772d92026b6cc0d2ab3c71fdac6c1c8cdb83833b93da346d57430676996f81f2abae44af4faf0eb31770c7ca8106f51779e05e3f4d33e2ff52bb5d98867df547cf051454c50ab5c0f71f3418b13050f03cebe5cc96450b6c7fc0c42640013a75d7a1a7522aab861add4a3daf2742ee3431dbbf6aa76d757cfcbefcadef8ff663ec4bc65b356b62ef0a13388fb275d5b2c002a0ccfc9c3556d02eb2e3838b955dca415cab1a869580e5a89f449acb5e0ee70aa813baca88dd6f0ca060a6a8b6e5e75bae3317c19d751f27232afeb47331fcddffb89a36141744df74aca40537c7568112da012e66957d9d7cbb1313ea4b76516e27d9258a05a292c09a2a813895656befcc9a4ea30f5e43e0e2e44ecc60e54f0ae9c92d89c196400f73dbced4d274e2320fdedff7abc0306048d66fa3286deccbc2cd0f4521f29ef7bfa7afd7f36dea3c229729bca0d2e7014c3c72557e27a4f87da9f12a062efc50651377d4bf20d535d6d1f8ae2b6c10842a598ecd03c80af10399c1b5742d4b120d99eaba92ede77ae1cbd4a124dc2f21563209695af17e09ce2b0c1152c4299ade0d2f7f3522e94636f88a3b02cffc7705ee57b44843aab86b0026dfdbb546fa19fbbfea62c9924d79b8860fd9cc9f06e05d82edba1ca092ff123d967c2e2b39e9a7c8286fb3b6ff21c83bbd8efa7b09b4f99b3ac63d33a8bab4a78f0c5199e04f977dcaab145c9e121f5a3b686ece076c96f98bc8139fb14e024786c79ddd0326f2a442f3b09a4d743d7996ba406eb6f92cae50e9f0429830e8b561a36de11932b73cb679b2e7dc19cbba8ff4a8fcb35c1bef8d8929e6a84b2ef7ae7912651bf3650aaf2f4c2e7c046ae82a73083daab4f122503591cb5eadd2a57e8e923d51a06557136c041e80100e48fee4de14d8346f6a98ae5af9c8bcf04234dfcf681edf5287e9ee1e20379d84daf4d3338e66d08e87169b8f4881ef271cab99080238f7ec61a27572f37f8dea9f14fcd8113308f04bb2e4d45ff4341c482eeaec6e57711e75716dbb965f6ba9391f377330ff48002a97635dc193ea76f8d6f42400ec9e3b21d259e0275968f14b25ae40f98251a866da38497c37794d32ea13cb5ec77eaedf53dbef3ca935e87a1d1355863476ccd66ff023db6d0da2ac15b0875deb18964ea38a71422420b0068c5d7bf60c5828f9cc44544c2a91b45be968965aeba9a38f80947f1aa6d2ee86737ea6c34114ff9fcb00327a910e6a24fc1473e17160f92f70f565ef12747694bfd1a9a14213f5efa0cccd8896cb31d652ba0163dec3c6b502200ce47abc8df5f788689739f545cd934fa7c149d3d8fbc4c67bd43c535fb5c7dda5a1dea638e0ca438395131371dfcf6b5d7cdb39b4209899b4af6d8ec1833b0882884bed52137467f47ef6da8b62b925c8cf95f4c8ffcc1461899e67895d6f501cac041fdfffe90e1a19e6a8d061fed887e48a48cb00a4fb4b7e2981291b71d271323d197817aea1343276753139542ba968afae91ad4e5e7f618437dd7454ac1a3fdd6499faa32f5db37088941d17874eb91f7c037ee8a8d2efdb49cacdcb66e8e4ea97acf4c15f87c1bcd195bb68e14a0e9f0906fd4f108ec74d2917d9d0cd4f6ae69f26eeb9133bd7a54e4d7fffd4227d8b840366de2ef07ae4555b862f815c43ae2facbd7893c8fca391e45573846aeb8412230c695a6afc94192184edc2af89eb8c78f4d1bfda194f382a4742ee99340475e2422d608fe3837f058971d887fd66c69eff93abc8e3956f0ad761b52798be2cafd15aa499257bd76f97d5294995429526d5f4614f7e848d24cea47da19abf5ab83adc30ec5ff058c72dff4fe322087941f35661c528950ddf0974279329b6d0a9fcda4f85711ce16621eb7bed5af4c6e5f83d5e9f551f677f94b5d5e992d4c90364dfe6bb0da00d0faa12af293287a291d7fa126a83027b1ff3f1582a9e7c3cbb87300e1b9d4000c08d4e22f3aa7d870c1c8b6b155474895e1ba9adf8c40cb729e1197bfe1c001c67a06e8915c696f88da9371666a8a73dc0b4fdb6b97d42d743592b3b9eed80a542f502ef7e8550280280255e36dccbf9fa71c1e437c8538497e255b35be3ef4e424f691ca7a9c1f3d3d2998dd09a5f0dd3d" }, { "category": "integral_bounds", "entrypoint": "bundle", "expected_failure": "minimum_client_version", "id": "bundle-old-producer", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a20766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf55b981e46e06c8fc17b9572e752decc01327cd4452b05d3cbc3c129b97cbc05d80c0cb4e2ea854ed43f6d99abb7aabb9e5029fba6c12ed657af80ba0edc3bce371c0c077c0b8274622b73842743abe53c8e5a644ace240e546cf512e68115559b1f05f6b4ce19c0a9e54100fb3720ed175e80f2926df454d9bd9a61d8aae9dde28a80ad8fc2a62496bfd367672c851b8a480dc067c9c767a85a7e24b97d9fcfba5f938739d4e63d6f8e48b457d312b5f61ba46fd068fd9a67483bb4f7f73c5a2138687a34f19fa8329bcb1a54463f4e2f0d46a18300d49a262e66a6d36f3b5d29c4b290aaf64d1e9c487291b48b08d3497548d33ac887a034b1e7aaec7d567e576329607546eb4497f4af012d42af8a2033f3f8f769f40ef58090efd53c81b3aa56e4e4650dbc747f0d63c8b9c01c00f21b4d2581058af226bb7f4808110b18946333685b5406070188ee3e6a15f5a09c6e90cbe456b8823dedec13a33ff96b1c208d43845ed324d0081044e4a3138f92aa96dc6de7c4442970d5ab6f2a36cbda90e62db20e91ebac3ef682d2d797037d1706fa42d6bcb2b527f718f159cf7061350382b4d9e2e73ef8e6da312916f30f75911f3872780971ed56f548e17fad8c17dbb0449f36909bd150bc90835f5290eba9d2935b234581c2764be57f81785eb8bab6152cdca074ee16bf7d695e21e27448c2d84d62bf96fa51e35e8ff95847c801e9f374303ed0be2aca51beaf3e333d2c51428af2556fa16acecb045133873c1c21e46e4d8bd11034e900b045a00a5f9cafce2fd27ed92899c31f723c9c50fcf0afd7a3ec0a01916e52908c1cb3e9a9757e89458815ff6b86b98ba7d4cff22c38eae9b720b6c02b29c26544a68727c0b7eaa561070731166a8aa1b138a32bcb12bb54071700e9f074e0cc8c0bc8b6dfe659ccf4cad6d970dd20b563a0f1e89e4c9670857bd3b7fcdd8f8bfb0905ac72bc89ce09fca52d5e7be23772383774d12fa8184911ef6fc746f004493080a54894f3b054df3a8eeb71a39d3448e1436366717b60cb022f89b71dd78cbf1c3b8903c4a3295830f1ffd99d2a68ea9273ede80b699c33cd23e025885283add1f79b004c81daa6b7efb4d6228ad405e00b5b0e2d1bad9aafee1320e907ba5e07bbdfed1f8ad800f65f56ef3ca13c03f4a47f5ba41db686504e03114d52a3c63d418d2f423b6d8e617a6fcbced4042b76a5834adf9e96cc009cb18ea3d157332958f32c15c20396b038b23b5dcac8135f0f0fb0ba82ea6283da098f5ae80c0a12002328ebbd24e0b82bad1d8329c692e51a86cad7570a24eb34b014fe35bfccedddb3d83938a203a00dec70bbb63142ce0adc4f5ce19dc08c388ca95031b4db28331f41c78a37092d8f8001c2e32ef63a5d1bc52cdd01a66f3458a36b3371a783e72f4516d93708d0637f0fca4f9fcee28242bf85b76781f60a1445fe8743370b3f3891d63c72fd6b0b5d58321f84d9227242d1a4aeab9d27856e2d1654d136b424ea781da5952e47ade11669241d938b01cea6923b87335d97690df0499d6b55a52f6c2f5baef497904da7e868d574b4c139944ddb7ac9aaadd5db01cc903c32027ba77eed056f6473fa004ebe5fbfb0be97dcda625b75ffc6230e41713a99e9a342bd6b27a7115f950afacf33bba2e43c8d75940c2bfaff6dbe4a0f7e43a19221027d182c4df2613ba3935063557ade1fd22433219ece866598860dd06cf91bf2cf1c007c4e305d1c58c8728106e1491293b95ccafbeae9fd05c315312549caad07e8bce33fea0c4c3fee4ed8d101bbb67f71266048b6925366dc41b3f7631d5edeb2a283d0b3035f3eadb879bc4c523ba2cce69f71020f88afa394459fc8bc93f89f8d646a97161dde6511e277ebcc7ce982c7ed758f79c2f3386a5d0343aceee1c0fc1b872026b91e00b776f0cd418dabe501115f596b2cfbc627304c717beefbd6b22d7d75048a02b03a5d03a0ec115b72b0c0a4138653196366a3655dc2f97eb1ad05257460a34e9f90af031f1ede99d1aaf7cad79a1f0a0dd7c00fbad9d5f0507f56d09bcb8342350f2ed11db9a2d76061b74ec6db31bba5f5ab86069df2cb35ed422fc4b4f34cfa5f30a02cf13ea098ddba3e7d0bea0ccf67dd594bdaf2394341f5be23300d31aabf5969852d8d844a4c809b22d4cc5a9d0511681be50fbe3f5066f80a3f10a00dd84697d81ca8ab9f8ac6007824ff24c259e7236b5cff2e6aabf94cc20b66e9ea349ecaa175e4a0ad1756853a3d6dddf8a1bf17ff1ea29cbcf57beedc03b7a8f1bf0cbada11b6ae0c6ccf7669da3a2174aad45ae3a1619f505eb949d231a95f6bb5469555af1de6a5eb2023d2361a5533001d5e0afb75e2f4c8c90ea6266b91baaa8c24fe8a69576b3bb71a2ccbd61f640182eb2447cce3d777c7bc9cf42783f6b8e08107d5b370aed26a241ba0667515f4615be2515363184d718ad4ae93c1f1f5f3dee5bb380cfd5e81ee74f07824a1e79831c31a0a6d7b70dbfb4511ff703399adb389a031104a694e22e29c423f3d0ddc30a1964e954034eb0f742f1dc839bebc05efa7b6b10f4ed5d1693e870f0733fd1c0641a05b074b595b08d748fdb83a58a806eddac4a8bda416b7e1360778a381d13c0d059daa7d6da1c873313f91e693385d928c2e46505ed943d4f57a049b82709075c782916ef75825096c4d6e9289f561e65f5b1cbe85ce6ac4da914df8040c095ed0deacac6f1236b42b574595fba36adf6c96ecb10d1a42be6dec80864fa8d57a3ab0c2381672afc38ef14627fe619ece92745c23e1375799ed09707d15e693d4ecc71c6efe92f3c660a62db196d418aba8eb74ef4413c944281fe22b9a75452c4be449232a2e227519716fffa69304748daedd9a61084b38866e0f7dbc64058506c7d80e947ae25e280fa8812d8c9705e300c5f9c363d936c4065c7cf41552952e4d02ca62cbd7bd426c170861801558ec78eaaae9e6c3bad2bbd64d182d5d809efc3142ee996e35f6fe500cc5ab3d9b487935d86e75465a68f00fb68e416d3d0b1860a1519539f218117e2a99c19104ca454b6c7a95970202681c3127767d1da054719d94b896be48ef4517e866a75d660564c5eb25b5cb5be8ebe489d0b248acdc5913e41f677e8c4e68933d86c0c279f9a023747132c237730434dda2e0a5cbe6ab0ccd87df2967fd5010ec68b780a4f90da9a8d0e8d04410362803b05cedc830a953bd4c3279ad116f758726edefe083b356fb4827556661eac3a0436343def00ca3384aba9e6af9d5f3bf22dad63d01e2b428774c09ec4598872a409469cd02bf13fd5e2c6fcc1b367db454ee55c176032820f96f879f1040c44829e5d0e4d49f92f18a9544f43e23a136748cecff491ff18da0be2f998175a7ca431768ec4ed4d765ace7c755dc59335d5125133f880e168747dac7e5529ddb36e95277eaef27230819eab95b17f00157abf745c81c5ebfa9cdfa4379dc2d96e2f4b5111ec24ab23f8785ac2371e351de462da44bb27c750c5c6e67eaf40fae27c960ef810f7a918da06239f17f6cfcfaa445fcba527b8ed17e68a4b680a3bcba59c5a14621681d96110510bc0c3fa62e40a4870cfb1735fb39bdfcfb51d34c7f9f2ee74a49ef8a3e8e1e7b4c5318161e4e4f5f75f5e3d7fceca5f0b5aa492eddf46a779b2414f239257ba67e955bbf6a6f05159162ea57c0307ae8c8f344aabac238ff35dfc1b0f96d15d3597da3c2e7bf3453b2c26d4383295cada7e0bac00831a2bec0dba3eb97aca0ce6aef115e391ace22cc6074af0040fb963a48c36b68e107e88328ad824761a6128d2828b318cf3ea83dc919d07c6950fd045f3b44377ed16aba1d62f7fcc22a99e878c0e9522b7dd66f7528d00634150ce83dbb5073a52885e1d47cda820571c8a0204948f4acef220d2f3a703293d19ad1ebed060d21897e9aea5f16f2a30781dcef8f72401f95664d7d5c0a175cd64c1ba9567e4a685eeee3dcb42eab7f6c81648fc976a472b0b16819e5221ab762369007986ff2dde11a9fdc8b80fd9c3405570c50f6723e8967bd630cad360bdf207c8532dee18dac1787b8b33c92583bd433572140f3524246dfa58c8a2bd4ab98787938291012eab1d0808a15ecb4ea16cbd36d93057915e30096ef0fd0bf3f3cce430d887ff0604c28dc3b8ad820df8c5922fc72f378562e1e849c826001e467058a6d630905af7a11b2e104d12acb340e200688afe3aebb78bf76388eedca9a89d4e4796ae162829428bdd452bcc" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a20766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf55b981e46e06c8fc17b9572e752decc01327cd4452b05d3cbc3c129b97cbc05d80c0cb4e2ea854ed43f6d99abb7aabb9e5029fba6c12ed657af80ba0edc3bce371c0c077c0b8274622b73842743abe53c8e5a644ace240e546cf512e68115559b1f05f6b4ce19c0a9e54100fb3720ed175e80f2926df454d9bd9a61d8aae9dde28a80ad8fc2a62496bfd367672c851b8a480dc067c9c767a85a7e24b97d9fcfba5f938739d4e63d6f8e48b457d312b5f61ba46fd068fd9a67483bb4f7f73c5a2138687a34f19fa8329bcb1a54463f4e2f0d46a18300d49a262e66a6d36f3b5d29c4b290aaf64d1e9c487291b48b08d3497548d33ac887a034b1e7aaec7d567e576329607546eb4497f4af012d42af8a2033f3f8f769f40ef58090efd53c81b3aa56e4e4650dbc747f0d63c8b9c01c00f21b4d2581058af226bb7f4808110b18946333685b5406070188ee3e6a15f5a09c6e90cbe456b8823dedec13a33ff96b1c208d43845ed324d0081044e4a3138f92aa96dc6de7c4442970d5ab6f2a36cbda90e62db20e91ebac3ef682d2d797037d1706fa42d6bcb2b527f718f159cf7061350382b4d9e2e73ef8e6da312916f30f75911f3872780971ed56f548e17fad8c17dbb0449f36909bd150bc90835f5290eba9d2935b234581c2764be57f81785eb8bab6152cdca074ee16bf7d695e21e27448c2d84d62bf96fa51e35e8ff95847c801e9f374303ed0be2aca51beaf3e333d2c51428af2556fa16acecb045133873c1c21e46e4d8bd11034e900b045a00a5f9cafce2fd27ed92899c31f723c9c50fcf0afd7a3ec0a01916e52908c1cb3e9a9757e89458815ff6b86b98ba7d4cff22c38eae9b720b6c02b29c26544a68727c0b7eaa561070731166a8aa1b138a32bcb12bb54071700e9f074e0cc8c0bc8b6dfe659ccf4cad6d970dd20b563a0f1e89e4c9670857bd3b7fcdd8f8bfb0905ac72bc89ce09fca52d5e7be23772383774d12fa8184911ef6fc746f004493080a54894f3b054df3a8eeb71a39d3448e1436366717b60cb022f89b71dd78cbf1c3b8903c4a3295830f1ffd99d2a68ea9273ede80b699c33cd23e025885283add1f79b004c81daa6b7efb4d6228ad405e00b5b0e2d1bad9aafee1320e907ba5e07bbdfed1f8ad800f65f56ef3ca13c03f4a47f5ba41db686504e03114d52a3c63d418d2f423b6d8e617a6fcbced4042b76a5834adf9e96cc009cb18ea3d157332958f32c15c20396b038b23b5dcac8135f0f0fb0ba82ea6283da098f5ae80c0a12002328ebbd24e0b82bad1d8329c692e51a86cad7570a24eb34b014fe35bfccedddb3d83938a203a00dec70bbb63142ce0adc4f5ce19dc08c388ca95031b4db28331f41c78a37092d8f8001c2e32ef63a5d1bc52cdd01a66f3458a36b3371a783e72f4516d93708d0637f0fca4f9fcee28242bf85b76781f60a1445fe8743370b3f3891d63c72fd6b0b5d58321f84d9227242d1a4aeab9d27856e2d1654d136b424ea781da5952e47ade11669241d938b01cea6923b87335d97690df0499d6b55a52f6c2f5baef497904da7e868d574b4c139944ddb7ac9aaadd5db01cc903c32027ba77eed056f6473fa004ebe5fbfb0be97dcda625b75ffc6230e41713a99e9a342bd6b27a7115f950afacf33bba2e43c8d75940c2bfaff6dbe4a0f7e43a19221027d182c4df2613ba3935063557ade1fd22433219ece866598860dd06cf91bf2cf1c007c4e305d1c58c8728106e1491293b95ccafbeae9fd05c315312549caad07e8bce33fea0c4c3fee4ed8d101bbb67f71266048b6925366dc41b3f7631d5edeb2a283d0b3035f3eadb879bc4c523ba2cce69f71020f88afa394459fc8bc93f89f8d646a97161dde6511e277ebcc7ce982c7ed758f79c2f3386a5d0343aceee1c0fc1b872026b91e00b776f0cd418dabe501115f596b2cfbc627304c717beefbd6b22d7d75048a02b03a5d03a0ec115b72b0c0a4138653196366a3655dc2f97eb1ad05257460a34e9f90af031f1ede99d1aaf7cad79a1f0a0dd7c00fbad9d5f0507f56d09bcb8342350f2ed11db9a2d76061b74ec6db31bba5f5ab86069df2cb35ed422fc4b4f34cfa5f30a02cf13ea098ddba3e7d0bea0ccf67dd594bdaf2394341f5be23300d31aabf5969852d8d844a4c809b22d4cc5a9d0511681be50fbe3f5066f80a3f10a00dd84697d81ca8ab9f8ac6007824ff24c259e7236b5cff2e6aabf94cc20b66e9ea349ecaa175e4a0ad1756853a3d6dddf8a1bf17ff1ea29cbcf57beedc03b7a8f1bf0cbada11b6ae0c6ccf7669da3a2174aad45ae3a1619f505eb949d231a95f6bb5469555af1de6a5eb2023d2361a5533001d5e0afb75e2f4c8c90ea6266b91baaa8c24fe8a69576b3bb71a2ccbd61f640182eb2447cce3d777c7bc9cf42783f6b8e08107d5b370aed26a241ba0667515f4615be2515363184d718ad4ae93c1f1f5f3dee5bb380cfd5e81ee74f07824a1e79831c31a0a6d7b70dbfb4511ff703399adb389a031104a694e22e29c423f3d0ddc30a1964e954034eb0f742f1dc839bebc05efa7b6b10f4ed5d1693e870f0733fd1c0641a05b074b595b08d748fdb83a58a806eddac4a8bda416b7e1360778a381d13c0d059daa7d6da1c873313f91e693385d928c2e46505ed943d4f57a049b82709075c782916ef75825096c4d6e9289f561e65f5b1cbe85ce6ac4da914df8040c095ed0deacac6f1236b42b574595fba36adf6c96cdf0bcca82183d0c65e62e9824ab9b61125807a7be459cf4d266bfb1ba3f13f2ec6230c687f8fd1f109df0e7e395cd0708af7f14e3067026dd50b6a529fa788b65ff85b329d4d94ff25aebd5d41d3a54b9d3ea384295fc10bfbf22e2806119ae0d9bd3ba8a59e66faf1daf77655167465c0e447b57cb993fc9f09c5997e507f0e5e9d2d26926b4a59ecd2455d1363ebdf2dba7da563c727c1628816544383c7bd81b39a6401872aaa58e386d0d80cf5920c36fa97e34d30b40e9a41fec1ace69f5c8ee85a7daf9c17e067eb1ad2b6eed75d4c5d478223ce0ffac890181843aa4cbfd4be427f2f2a80a51c7937c6de175b189553910ae88fe947759c6178c17349404cba4b43a0b585bf2e93057297c9cdd62600af37e5c4fda21fce7a0c3d999b33014a55343b373d43568375195aa971ad9cd673bd947ed41a15c78c7236648dde92b73f820b401f33982315ccdc91049b6ec2ce27a9fa0bf3504844dfe93409316ab4876f077e06bc3804246b6ce156d17e9cb3c8eaae804930e826a276db1426458e119fcf9300cc66b90b0984c84afd3ec8f4cde6cabc72d04f43e255726e3cd21892ea65f048145ce3925c135b42e32406ab544956e62b120146c099e995f75fc24abbb9951741578827608edeeb4c476cc33a6d45859e35ce4b0a20fe97fa752473a2315728dca87594207492d96f3489a49a9abe7e1c4462a7655f81d7e5f4c9c9a937aee9c46a27717b10fb189927fd785fd866062d0ce762c81bea29914b88defe1dad40fcf47c9a02ee01f6ac6a855774c653e2d2cde55c47a8a727befb17ea865d790b3eb9ab920a0366049dd82d084968c2c3fc74e4064772d8b2795abd94c3c4ba0c26c7eff3887aaac8e5a7e0a6fff59c718c61ffcfd9e1495e2779cffb69061cbc8aadd246bc20e9524934894aea69e71ab9f7a7eb4a58112499571e03a6b6be3b45aab07297eb4bf81b1d98df08239ada0f0566f74d010627c3040091cfda7b11fd189b0c2fec1fe56fb72cd141feb68102e794b19973975013b41012bb2cbc9446a18a1563f33c84883372087d299e9a93439de0ec9ed69b8416c98f04dc42e6a74566ec70a5af80327bc465b1dfdecc0395399cf170e1529706627066c3c5c3ad47375293074654cbbf3b4a4c840c4b968e4bd5e921d5f99b28343911972fb1df61ce498ff1b9a0f17d243e2d0bc3f8c40f1cfd5e5efdd4d79d47c961c1bf8f36fcbe92ebee2f9413e4e1ed850e508c9071472c0c1fe4c10307a53224760134ddb91aef0093dcf4905892896d4374c7156c6fb1d10ba943afb84fd4943df16001d93fecfa6a53ac3ff51fa7c0027e6a110b23242434e90a97fdf937e6c0928f99930e05e0430842e24ed048b624bb2097365d90197d5960f7be06f2f2c6e136aa85a7494dbf93d0b9a6844ca79583258c3d76c529f3e751fb0f1105587c68bd8069ed01bce155370f0e5ce8ee02bf1270d5b376fbaac6a425fad2f4b4c58c38bf3315a7cc4eea129ed871e5a37c193b09a773e3295cc06955003e54af324d81fd8b0876eec7d1e470d25473d3c91cc9abe792b36469614f00c3f45335b8958113981df7c19c0e08aaf77a5aec97e73e73a1a2b1d2ca96731eabd2eda0f1bff7ec8a12f634de10bfe288d99c5bb1bf5c390b00d4bc5e74bda2192831ab3d4064673cf9565c4ff745e0a3fd353da634e5392c06e38408625f1a7512f548c4f27a15ecaa55f93793cf36c66df665f0a9e56c94ef41" }, { "category": "unknown_field", "entrypoint": "bundle", "expected_failure": "unknown_root_member", "id": "bundle-unknown-root-member", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a21766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf2e8827676f43e9e505993293c25744defb620cda44f1f5218cf1ac80e2fe27d7abd6afc884096126c0f83c3f4a642188f99e259292b75655f69def04ec122036d9306e26b9cefe43c8f76b379671ff7c6efee49c077c989c17c4296dc188f70e71d792edd5d906129cf6fb712e1cd3c4f26a4889e66c95b4bb6c4c309b340a56c1d77978df1a7c8b07d37a382d1bd367e634ec73f5acb0090b125cbad0a729b36446efa3f307ecfda6c666342128c99d0b2388a3caa80998bed7bfe1b68488fb832bc3e6ab9ff65159566a9ad71f0435c3c40655f3397fb3e5b4281131afd4586b3c6be9dac2780c1b6e5ec1864face95d1a767385b581135b71d04bb0d494acc7911085865c8802bce02d0719bc460d8011d0d1a6420363e11396603ed6fbb045250c0e8e3900c7d70a07dbf870aef0c249b535a019d07b54bfc4c4d6b3c57ca6ecb6026e1eeaf768f515b01114783d3eed469192644e2758f7d9d23c2b1558f4e1a6200e2694d799eb5eebd14dde9ddb3d37a51cdc312c7c37550dfd57a4a9d8bc1ec00512588cea9ff774b557766a119ace35a75ed76eb27ddb2b911caf8dccb73ff525709a0d740379f963319f2063dbd5d47a7f417d969cd37a6722c979b412f9f266eae36e5087bd83eabb8c3c7562edf80739faddb71c53124fd5ab67f475ac8a825b707ab93022320f924bd06a4975714962fa403b659ecb1b27d576e14d6fca7012a87a571d0a8c18637bba1efd17ec3fa4bba26fdcaa68b691e8376698e6febfd3b949c4c7c967ad1462339254b7c76832676b433cecc591bc00a45a5b9f4399b311a32144c3635f03bb15ebd04c9b18385e591d115061f426458b0405d6c5b94527b32a8015ee9945701ad382ef069e9d289723e9a3d57d4c90fbbe907a411e8330ba7c1352584048f9e51bdc1ec85e02d7fd59c7e0c16f32a4b4a7015e60b6f4fe29833aadb3311f83a5734b802207a4bc06429d055ab124d296c410314a9010b381afad7880969df42f1953df8a4b208a80bca5a96302732d765a106217c368e0cea263c5e196a1845b04010553d22eac782bb8d61551f096536e9eab01f5bbb9046330626c1774b5c948680ca284d421d591450a1fe517e3725d41e84b5eae671d0e3e85c6f08dba709870e33f74de3603533a0787023474ce665d45cbb08ccf3516479675b88804991cf6799ddf1c8922db1c461e756d4f1a903a8ea21360a411c210341715c883330010d77bf42e886ddf2b396c80aa7ca8b56a1676a13a6e7503fef59e0d6d08ffb1dda9e1d356800b60dd04913d27f68989d78278153733fe0b1f38a0113c34ef03809e32926ee507e3fc73978faae4642d7de6f463b1b256a2f149acf3441e45a6ff50ad10459604f295f8b923f285fe35c193f161c286bbdc010af0e84aaf22c1983607bcef7d3572f15d7d52ee4bc01c49c3c2c354ccbdfcdaa9dd1d48d41749b98e48d663334699aadd7f8f7749eca0d5faf45251c6c05dea633227c1814f78259387ff6ab15111260ee103ec0be9a206990fbd5dfb682d650743bd727cb4c640045d962afad24d3374fc4aae845cbbeb31460396d64a313dfbf29c3e36ee733d195245065f6013ed7cc2d52e691cbcc54a285c5082f199fa3f9c762a05d15c282c62ece02ad67a796b64bc1220dc4f8a926d36e291fdec145cf3ff7b85d1970e37f8bfd18037e22b53e4f221ec41825f3055e24ba86718348534da1f0b530d1360750fa5dcb207660a972a30b5af0f1f47c0225e5a256dc14a6bcfadcd2e78345a414ba47d10e60f69eba8de80de74e77177276f1d9a74f58928a2a8e0c9956ef315a5c598f67b024c7d3b62ace5fbf3854b96378b006b368934acb08b6a2089dd56057de8ebfcde1e449a5522aabc34413ea6c8e94ec20c69b2659fbf55f86f245a35035fdf1eec87edff16eaa9c99b3f4223296adbe7fa10d68ed9eb5533c25339bca3c93ac08354d2df6c58a88397fe05fc234667b7f042f6e2a5bcd2e46dcf073818116e21927493f7a746d0bc8d084051f44fa0923927ee2ae119965165ea288ab84b56004fedc697a7d4469e14a72dff14f7f77888fd2311925bcf30c53b7c0e9a8a0e53f4770f74870e0c0e16493c96a7f8a7b8f205e7851a78d952e69b409df249bedc167de8613a36733417748a877eaf1957a1d0fb4cf62b8fdb825bc17eb015ff2a95e8c366c71ab6bc5bd983ecc4f182cb89d1a94fe2ce24a9db3dd7b6489f46c6f36bcfb85a6589a531e9cb271352f24080392b24a6726b74a4928fd1489c081780c89fcc14a71b8dcdb92d68c74acb9330aa14bd0e1ed0494525bc559441e289e9ffad7c2cae64145eecb1023339ed43c14a2d6ebfbe3a8b14f45711edf6ff67fa42b3849fcc42a98ef1f24d85f8f72ed421e56e4dafc4586e17b84ac8ddfcdbe760285ccce48ae5ce628502bd0193d7ce3060fc4cdaf460158a794fde023e7cdae420468e4d7593bf98e2da1dd12e1a6a83d8dba4d9c4694a61ea00589f0b7ed3ad3cf418c7163765e00709c0fe9476956323a22f9e61b2fc8d78093fc09addeab510452605530b8e3b4bf5e077839a1cb19ef339a73460153a763ef87442df6defe0bf5ce9338f6ff2ebfad9bf90ca3fa6e26380137d074d2906d7334c931262fc97cabe07bd02121f9d519bca1af457922ba528549cd0dd11d087ca4fe07807777b3e0b70337aa9af87abdba7f3d365ae3079e66195d32315e8a3cc09cc4c38e2f98f405b589dfea5389ccb96fd1ed01e0b8c3f09890a431bc2e6cae8b005393451db76fcc3c643582bd135a52526ee09c7daff0376c9fc060c40ed11917edaf967eb7f9a46c45b8212afc479f69019d73c7038b40fc3d647f8b6457b7caed70c383f4e690739d7f75b7bda5259071421ddb232d929a34dca0d7d149a36fc2ffff2b92e619d13f0d253b3788053f57d1350bbe192a1ea8505f2ae51caac26d26078ab963ecc7da4d98649fcd64dde2ade13467c7d3da46d180bd7d1a7a005830ce5bba54cccc13a69b2142e7d86f2be38d065af400965bbbf94177bb9a6d93838ba8c3623efd778517166e070750c2ed21fdf1b01242a5283f25d19dc727ebf62b1a6c3c5533ab8285a874692eb84d3a9cacb86d13b93703abe90a444e24e51f8b1f9d1fe26db67e0acfcc0c2a84f19cff7b6978036dac90bb059efc858ded0567a88b1564f789408817d558c58cea50053eb5b9da7db9cd710475d6cfe40bbb8b8c69fe4a15bedf498d84bfc736729c485c03093ab11c64a6e2df9fde70e51635a1e21d359d5b037e156982d6ced67a5798d5683d8e1139650a29c70dad397fee241035eb0dcb6b3ea08e2f78dd49fcc98bd1872a8f4de6a60be0c9f0bfa9a01b8ebc93b1eb56ff6633c12146d61b9679c0e3d66f34bad137c05f179fc6f155f1ddf295964d1f9e859b0a5ea931f48bb8d77c1c7e189f58c6eb5e50a229993e5384990ee404552a4c40cc06eed5ca6f4e3ca2c7356797c8231b453baa9d0dc48dffd13d6465b54776ff035c40cd30302720093942d0353da92b39a261a5b06d05740816182036de8887bdabbfa6ae3c267f0a65229322bb025d2ae6dde3b7fddfc3493d41316f51b9fb75c0c3f83da0036996e84b45c2b95b0b814714004d1febfec7ba1ccaa2592cd8be776fe3f9a6fccb9b8fd4a6f7695a4243869372490bd2b8d768e4a482a08f135396b70988a160bc60b11c3cd5a00f46157c71756f7056247324cd859eb0ce0453eaf2f977fc9b80ac336cc91d9861ee0dc7f2364962c5c926b0b6d9726450a75a433d4e5f06ad05adadad0cd7c11fec6fef909a56dcbccb489920d721d82ef13a9a7ed1b48a3f3b81ff21b85cfa8854dc41ce95a4bf5a11d17ead3fa5227c98222a251ec47490856d3ab9672f5a1c79ae7aa961beaf98c27dd9be3045ce01f8202f82286ff2e1b53149c8c7a258bcb8dce2bd1ff5e54e0855ced588d9ed8865fe896c28484a3c531fe2bc6d94270f8909429f6b6f59e75511345b4cbc94f33930d204a9213443f16501dddb2a5fae55fe3d3957ad730a066f321abe320e358e575d30e2fb7bb4bec054d495e506be7129271062bf1770295ca8837b3b969395f775e0cd0904f0f88e4b81b4a58cc0b8f5276c26717fa3a01c4186d7bbc11250faa9ee8422adb8538cb975910e0f10f2a3e8e7979db09cfee008959380499e49f787ed1e888ec79721cdbfa2e65c50bb52e7a2b1fc6718309977b7ed73eeeed7699e0a18c07e7f8ff1fd65802abf82c33e16ac336c733433100b93b77fa52ba5751d1a6" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a21766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf2e8827676f43e9e505993293c25744defb620cda44f1f5218cf1ac80e2fe27d7abd6afc884096126c0f83c3f4a642188f99e259292b75655f69def04ec122036d9306e26b9cefe43c8f76b379671ff7c6efee49c077c989c17c4296dc188f70e71d792edd5d906129cf6fb712e1cd3c4f26a4889e66c95b4bb6c4c309b340a56c1d77978df1a7c8b07d37a382d1bd367e634ec73f5acb0090b125cbad0a729b36446efa3f307ecfda6c666342128c99d0b2388a3caa80998bed7bfe1b68488fb832bc3e6ab9ff65159566a9ad71f0435c3c40655f3397fb3e5b4281131afd4586b3c6be9dac2780c1b6e5ec1864face95d1a767385b581135b71d04bb0d494acc7911085865c8802bce02d0719bc460d8011d0d1a6420363e11396603ed6fbb045250c0e8e3900c7d70a07dbf870aef0c249b535a019d07b54bfc4c4d6b3c57ca6ecb6026e1eeaf768f515b01114783d3eed469192644e2758f7d9d23c2b1558f4e1a6200e2694d799eb5eebd14dde9ddb3d37a51cdc312c7c37550dfd57a4a9d8bc1ec00512588cea9ff774b557766a119ace35a75ed76eb27ddb2b911caf8dccb73ff525709a0d740379f963319f2063dbd5d47a7f417d969cd37a6722c979b412f9f266eae36e5087bd83eabb8c3c7562edf80739faddb71c53124fd5ab67f475ac8a825b707ab93022320f924bd06a4975714962fa403b659ecb1b27d576e14d6fca7012a87a571d0a8c18637bba1efd17ec3fa4bba26fdcaa68b691e8376698e6febfd3b949c4c7c967ad1462339254b7c76832676b433cecc591bc00a45a5b9f4399b311a32144c3635f03bb15ebd04c9b18385e591d115061f426458b0405d6c5b94527b32a8015ee9945701ad382ef069e9d289723e9a3d57d4c90fbbe907a411e8330ba7c1352584048f9e51bdc1ec85e02d7fd59c7e0c16f32a4b4a7015e60b6f4fe29833aadb3311f83a5734b802207a4bc06429d055ab124d296c410314a9010b381afad7880969df42f1953df8a4b208a80bca5a96302732d765a106217c368e0cea263c5e196a1845b04010553d22eac782bb8d61551f096536e9eab01f5bbb9046330626c1774b5c948680ca284d421d591450a1fe517e3725d41e84b5eae671d0e3e85c6f08dba709870e33f74de3603533a0787023474ce665d45cbb08ccf3516479675b88804991cf6799ddf1c8922db1c461e756d4f1a903a8ea21360a411c210341715c883330010d77bf42e886ddf2b396c80aa7ca8b56a1676a13a6e7503fef59e0d6d08ffb1dda9e1d356800b60dd04913d27f68989d78278153733fe0b1f38a0113c34ef03809e32926ee507e3fc73978faae4642d7de6f463b1b256a2f149acf3441e45a6ff50ad10459604f295f8b923f285fe35c193f161c286bbdc010af0e84aaf22c1983607bcef7d3572f15d7d52ee4bc01c49c3c2c354ccbdfcdaa9dd1d48d41749b98e48d663334699aadd7f8f7749eca0d5faf45251c6c05dea633227c1814f78259387ff6ab15111260ee103ec0be9a206990fbd5dfb682d650743bd727cb4c640045d962afad24d3374fc4aae845cbbeb31460396d64a313dfbf29c3e36ee733d195245065f6013ed7cc2d52e691cbcc54a285c5082f199fa3f9c762a05d15c282c62ece02ad67a796b64bc1220dc4f8a926d36e291fdec145cf3ff7b85d1970e37f8bfd18037e22b53e4f221ec41825f3055e24ba86718348534da1f0b530d1360750fa5dcb207660a972a30b5af0f1f47c0225e5a256dc14a6bcfadcd2e78345a414ba47d10e60f69eba8de80de74e77177276f1d9a74f58928a2a8e0c9956ef315a5c598f67b024c7d3b62ace5fbf3854b96378b006b368934acb08b6a2089dd56057de8ebfcde1e449a5522aabc34413ea6c8e94ec20c69b2659fbf55f86f245a35035fdf1eec87edff16eaa9c99b3f4223296adbe7fa10d68ed9eb5533c25339bca3c93ac08354d2df6c58a88397fe05fc234667b7f042f6e2a5bcd2e46dcf073818116e21927493f7a746d0bc8d084051f44fa0923927ee2ae119965165ea288ab84b56004fedc697a7d4469e14a72dff14f7f77888fd2311925bcf30c53b7c0e9a8a0e53f4770f74870e0c0e16493c96a7f8a7b8f205e7851a78d952e69b409df249bedc167de8613a36733417748a877eaf1957a1d0fb4cf62b8fdb825bc17eb015ff2a95e8c366c71ab6bc5bd983ecc4f182cb89d1a94fe2ce24a9db3dd7b6489f46c6f36bcfb85a6589a531e9cb271352f24080392b24a6726b74a4928fd1489c081780c89fcc14a71b8dcdb92d68c74acb9330aa14bd0e1ed0494525bc559441e289e9ffad7c2cae64145eecb1023339ed43c14a2d6ebfbe3a8b14f45711edf6ff67fa42b3849fcc42a98ef1f24d85f8f72ed421e56e4dafc4586e17b84ac8ddfcdbe760285ccce48ae5ce628502bd0193d7ce3060fc4cdaf460158a794fde023e7cdae420468e4d7593bf98e2da1dd12e1a6a83d8dba4d9c4694a61ea00589f0b7ed3ad3cf418c7163765e00709c0fe9476956323a22f9e61b2fc8d78093fc09addeab510452605530b8e3b4bf5e077839a1cb19ef339a73460153a763ef87442df6defe0bf5ce9338f6ff2ebfad9bf90ca3fa6e26380137d074d2906d7334c931262fc97cabe07bd02121f9d519bca1af457922ba528549cd0dd11d087ca4fe07807777b3e0b70337aa9af87abdba7f3d365ae3079e66195d32315e8a3cc09cc4c38e2f98f405b589dfcb12381c79c9810de480d9f301493175e1dc3fb9bf0d125607045c674b3dbde285c36ce28a334278807c98ae80764d5fd4a14588c1d9076d5f775ec6c8f5bc25a931eb5ca70f98518d02b6a31a718d8d948f4b7237572f8ca08307856285e79daf74050f6444f070e27dc8304d46fbb558a007d0fc36def29e8e9a47e6f935cf2d352a8638154ff41384dbfe9a890e58a04eeff49d2bf27d27276a5c328c17d97f29b4df4ec67df3de22142797d38be3c0013c4d0a7b25ad202b3a0e854d4a03668ba1b20658fe1a923df6aa3416f3aeae8cd536fa4a4962020db80372be0d960586271fb7f7a002fb411d14b1620384e82be4501bd7e7fa274babcc8c84429a724488f568dfe8fd8a8acc588872697787da6d1fd04ef4e4ad39defc7fe3cdd66d19af186dab00f14cfecefcc9330cdd216e057e1875adfd14d6189b65c7353890c8947648357cfb9514bf34b9fa7c992d94551506ccd6ba6bcb0fcb5e5b667dff09dd84ee6227f31d487c022ccfa1f90512335e19ceb0f4c7551042adc4cdeba3ae65ffd723dba3ee16d9054ccc0f9729a51bf8a7bc0d898ebe94b0e59b9b9b8bdba1fe6e591d5d3d480dc1473b5e2d5706ba00e9f0def9e10b98dc73548a86bfe2424534d2d6cb97cb7222b70f351ab124d33fc3fe47755521dc4700277d7eef344e0abe4dc1d49ba966cc0c0ebb5de916abddf3b2d9692f9695188ff434540aaca59d663ed6f8de9e7c21659576e40b035b153bfb381c450cefe13c4190a5a2174bd2ddc6fd1030464009f983b1252dfe0eccb71431f12cd550f9979e1016dee9e7bd6b7ec7be2c715fea8023e3639ac249eb675bd397fdbea349fdb1101f6119ab95310348ccb12368f6fe2ba527d88b4b05369015d91f3bff7508ddabc2588cb8ad640f37982778cb4b8e613346593b33f2539392adfbf2a8c6d954b4f2034da283d634aa985190ada5a09dbdb5e00e66f5992015bf736495a3644e273ea1bdc773aaa2f926598a012d532cc8386d710b6c7323b7ce63b5897767032c6711c4e61b9319bfde81ac75edfc7abc37241e9c2ece508e975d3bea59a941078179bfe1cb3ace11c5db0f5a91e94158b99b581458304a8030bf8a1063cc6c5ec523dcf8313945caa5f51485bd3b0cf29e6a7d081fafa9815a5fb8d26c680e20356dc349f06f018b7f0211a4f4584946d208bd983c07ec7f25e67cb9858e562a79fcfba6dec93c2819ef2dd29e92ec6cb1d21f6c68f64eeae85996e511442bdae961e159d492755935e1f7921610ce79399d2b214a9fe837ed52aab2aaa66db9c538633ca1cb5341684977ccb33765b767767db28b60c4760e9720683ca886ea0eb36661b0a014bc58c540d87cff3454a128f1b9454618a6101b43c0bc55f202bb2166602ab9fe2472ca9876084de240ef3f0042c7fd12968d142ddaf40c24b371caacd8e6360ddd893ba200f4690ba607cda48fb6a276942d469dc44db6b639a6da89f4ae09191a640e2d5b65ed74d0ae6a33c24f050ca9515e1b841107e14f8dc474db6f39f8d96939cb503d76b1e8180be2334389c83c201fddca2025aa5bb859b3a63362c64af6cdbca30b741b70217f460d67ec594c40fcde3887afc17fb5d46e2f1ad4be8ed585eefff2165dac45d752e69a98cac57b18282066f995d43600fa9d29c964ca7f55ebaa90c683f7732695c32ec596fda428ab65c9e18071d71cebbd52c9ccec57e0c4c0e90c1c10b5627d1cd34fcf9b1b7d1295b40d09063e0b90c2409ea479c049c7a4c4e8c66" }, { "category": "range_count", "entrypoint": "bundle", "expected_failure": "event_count", "id": "bundle-range-count-mismatch", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a23766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf59fed92bcf35daa17dca7d5dc3462317032fca363f26c2433ad87658163f084628f33d7c46f67520e963f5af988f122429259cc1992d88ea4deb7ef786ee5cc0d33e0d751d645d021301e020811a771d0ecde1b145655f5f93388e12b0e60ed08de2478ef279fdec9f513703b525ca37431d74f57ce7ee6767b240fef8ed42672bb7bf20436811a19255282d9bc573ce98a296798030f2760959b9972a1eb07cca601509135b40acfbd3fa9769ec1efe3942f72c4cd94b20b67789be7ccc80fa12dc0d64811fe1984c0daa860e76017fd6436e04597d8682c99d3f1ed06c587457787c4c9c392206c09383d08c96b43935c08f0ceda52bd0df414d57641f287e071468ada27d8c7b659ebdfc6a4eff74b4ebe6c7e436a9b9fba5e32eb2985d34529150cca3e068a0aeab3f7894f85fb562b73a6026ebe7355e569642299cecadf9b11e63443b698af0dcbf96ead4db35f421e0866ad191103334974a83116e1ddf34ef0ed2f8b9f247168267288282c36d87022fd952c5acc56c473cde4234d386a615ae5493fd2e049eb408c63baea4c129831851a8ab2350c41af9a2dc5678845b2a4ab5931ddf11e640f7f6008facc42cc4b0e2699c09797d32e0cbd8eec7230913cc4407ecc6e4aef7030217aff0ab20c873d0178a54ca091acb90abc9114f6210ab71b94eaec2715b21c573778595debb2c3544c3f9091de270870f6fba69c9fae2c293256b52543730afa9fbb5d4a2ff54c9b0b52b50b90a53b7e83c6324a55df6b0ed9693195b49a7045a1c853cdc6152ed88e34b22cf7e83d6be47842bcee8f316e06874ab8bca2bff8e2ca0c204480e09f2fb9d1fd942f394ef0e9b7e8c9eb7381b982bb555f5b29bae8a20e2e81d0cf9e1112575842e96e3100d666fec55954e9751dad1ed472ec601347b2db4996c62077ce93afd14b5c7232ba6c7959c20ee066943cb355a5844fbf92d03edf66a80cc3345f335d8d88dee17a516cd50f270a39d2860b950099e15d0470ce4ada96b4c22348ace8de9cbbb0bee535fe6faca66eefc05021fdea502fc26ef991c817eb71e67a3daebab0b8fe604265461973d191bffa448582567086f11728fd05880cf2507c9e11da3639c6b9cb3611faee86787f39c67b2e2b1fb604be6cac0b6ec3bfde3f7b14b2d2703db86a4c56494d1ff5852c4af7b1a687ae8c847812e3768b56f67fcecb8934874c0c4dec1063b475dfbfea6e80046da871c77d3ec8249494e0b874b719ede55a081c8e30601b2ae968f813a577b2495ce72e68ece80a58ed2a7163c7c05c078d06a8ffdc979b095a57d470ccd549c8bc7c11ee615a8c71983b3b38eba46f7ca22598e5532162453c00f0da037f078d1f9bbc2e8a789aaa758a816e4885c890f91d7f5d5e3ec261db38a7c3c4bd888acd66db6e5560e9fcb45ced4d8d8d2329b53672114fe7f5995a68f40584812edf27f32efc3ea531042376a4759a267999a89140e605c3797b290423d472b6487d7ade10d604fdce71fef533faf58126812de2bd882f78d5bef88a8f9e7483d1a3c4c3ebe7a7f05a1a6c0b679a0a71cdaea3d9143a41f7a2a48fa805f784001028f40be5dda2a63e1bc29bb0d5cf69014e0f1e7c7fc128259b2d629973f62de40fc04e443074f7d8db6e7e4e6f8d808a15f3cc0bdf56b87162f63007c47efba7e59612ac2386d46ab92599423df0d6fd5cf15aac417b3c8424f6f9f0471b8c0e1d3fbbdf4246e7c3f9cb448e09f44018d0e3bd240ed4eef4bdc9c9c76ddf00b89373bb809892556ab570f0a26f15003bddcedb0b3a65c84f4d9890cc817bdd7cd8dfdaacabbb25b14380804bdf127f7e4ffed3dac0c59fac4d5f42bebf31d1936f03e035fcfc3b0741a7ef0b728fcef508ec746204dbf402b95be31050ead8852a0023c70da70492e06c56c41b47103c5506e7df3e85d8375088cb4fe771d623dc06dea26945778c4e9e4f3e5c5bc73ab24c083066203c168e22ea722db71ee5cb60e3f1d9db05230b53274dbc3cdfcadc33ffc35e5857038ff5d9cd3609daedb262017f85268a30ae37875cc59a18c2677fe679137ad2776d0db4eee9385d33e1340a4391954dd134b06f0e030eb16171e1e09dfc928b9b4ad713a43038851531466da10fad959d48aa112c853bf72e14276232fbbb7da2298000ed5f80403270886d7fc22c0c557adfe2bb32c0ef8eb6994fa0ed9fe63184212e802a510afccae304eb093c0897790706c89ec8c0d9746423694263e5b690ab2bdab8bd40d914dd63397f5e56b43aacee54dbe06ab5a371fe49a04fa1928726adf860ee2b26e759a3f38c82a9251780bc38989d50e3fc70f5b9300650988af231400969e04c6f3cc71069554ed73acb310cb16ca1b4b8da67c3f2ca03d4a07008c0bdaf9288f2fde1c3aedbbac5322f051bc50c0618811f999a93b45bc060968f1d2aa8c78948944a8501f0a73fcac6259c2e2c71ceb9773c8dfca95722d5e198a806c59fd4f8d66db386bbd5dd176f0c179241f031d34fcf5747b1b84a21551a773d734bfe4fac21aa0d3d9ed25ab02f364a23e2a4574d81c390366d6af556d7dfb646699727aa1648c175cb90dc481cea9420bb97b14ab47ba1399457f7e34185b6fe5e4f49c873eba404dfe9a791e935db044cb40ef214951385873ef28bc4b2641d2e6c4357c3d785db3cb099baf736058fefa83b1ec1148eb16fd4bb02f8368594eeb5fbaf66200d0992ddc2d7077169ff96910cd81201fb15e3a766c5c7e1c0ee0e1e0165086f2c86f8a10c87d1018b4a163a047176088347a74bcd09ceda537f9621963d17c24910f6dea969d286f8c9ee64121d017e87588603c659a4089643273c576ed93f093facc1c4d0d07781963c93ee114d96cf622a24d5fe05bf23fed6e78bc89c9ad1aa709a03222d71273f9eacc195fa10f62c63c8b57850666d7dac36392e9f8ab917657bb167dbeb6dbe6245fd7649165626f5de1a467687ab3ffe6b0f47f34a4ce717bc72c6baa85b9e3d5bdcb14b835e52186be47c5d9b28e180f40f9b406bc6f9bdf0c17ee59b4d2c1ce863c55d042e412e080ee29763bde07c957f521f191f9e045197590fda9f4e86c41b91e10bdfa193556d4cbaa3bfa9546cc7565addd98bd1b346c52a6b0225ca5088cccc7b7347119dac2cfc56853e91d12921adbe987f851bfe0c7f67acdccc7a845f3c23f18a1931bbda507eb2e9fd7e471492b3b218ec17009d3908e7ab1e3c799d2756c0a6d57794c38848bd37bfda74b82fd975cd5798047704499cf1e115b305f139ae2d32ddb59d56c8024b2e587678c2fd0f224f55656be5de335213d2b90ad35b0540881a1c5606934be27b953bdd16a7f58f0478a71b1f44703a99289d24a87997ffb7b6f681b0cf6a85439276cefaf8b259b20999c34e3c0330683c346da25ceb8ec73e67e1ac6e1f6eb12e6568beba6d1001a5d26ba12d964f878e57b2f2b0ae4ba107114f286bb485a09d0ac035efb214a812ade3df0c7d37c7b604069bfd77c06d9a0d4d75738f0667b895480d868c780448fc4b684dfdef93e0074ae1e7ed09d14f4bd67e337b3aeda4e11f3733ab8fddd763f44f330127a852c996459570bedcb96d32298001e079de2fed1536ae94e1705c3730199d666d1066676b8214878506d295f037d9b6474aaf45379035c954af5627788ff62ed6dd286c22a6c5db9209208200737b8c9066c81626e3494fa07f077028c53b84058e7f533552cf13ab8188122f58d56c4333a33839cfe097f5f3117933c93bdf31f664970163d7662ca67bbc91b4df3fc46e6162d40ccb83a5e2be49568336c82cd22953d93cd77f1b9f4ebdf8dd27e51d1a3c5ea327637ae859e9e29850c23acb726e93607a0917a4ea1247b6ce5ae573222f862a28f2d5bb2d71b38126ba328ac04c6d849ed2b3fa7a25dbb37967616ecadb23748b665a81943d4cc49601e73ffe76935a3d2955f80c7c9dfe0205d37e68174eedb0dd2f5d766ac577d724d940a10952aea8eaa7cb25014e5d729867585e44aafa3f400395546411d0734c4580b80d8759476fe48321b7bedae94cacc0eefb1145394844b2a4d9b9f305ce942f8059c75f99bbe9d39d26753da05569d803d18690f1828ccb9b9120edaa6451a6709fac8135bf83dd0f400a6e3885e7d44450aa91a22b139ecc95426ad824dc24ea0b97f9d4fcaf45fae7306f848692421a2e8972678331cdbec8e4a4bd09f5565da5165b9a95dabc78bd95ffc9d32a0d010b17c4b6b5950b195d657" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a23766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf59fed92bcf35daa17dca7d5dc3462317032fca363f26c2433ad87658163f084628f33d7c46f67520e963f5af988f122429259cc1992d88ea4deb7ef786ee5cc0d33e0d751d645d021301e020811a771d0ecde1b145655f5f93388e12b0e60ed08de2478ef279fdec9f513703b525ca37431d74f57ce7ee6767b240fef8ed42672bb7bf20436811a19255282d9bc573ce98a296798030f2760959b9972a1eb07cca601509135b40acfbd3fa9769ec1efe3942f72c4cd94b20b67789be7ccc80fa12dc0d64811fe1984c0daa860e76017fd6436e04597d8682c99d3f1ed06c587457787c4c9c392206c09383d08c96b43935c08f0ceda52bd0df414d57641f287e071468ada27d8c7b659ebdfc6a4eff74b4ebe6c7e436a9b9fba5e32eb2985d34529150cca3e068a0aeab3f7894f85fb562b73a6026ebe7355e569642299cecadf9b11e63443b698af0dcbf96ead4db35f421e0866ad191103334974a83116e1ddf34ef0ed2f8b9f247168267288282c36d87022fd952c5acc56c473cde4234d386a615ae5493fd2e049eb408c63baea4c129831851a8ab2350c41af9a2dc5678845b2a4ab5931ddf11e640f7f6008facc42cc4b0e2699c09797d32e0cbd8eec7230913cc4407ecc6e4aef7030217aff0ab20c873d0178a54ca091acb90abc9114f6210ab71b94eaec2715b21c573778595debb2c3544c3f9091de270870f6fba69c9fae2c293256b52543730afa9fbb5d4a2ff54c9b0b52b50b90a53b7e83c6324a55df6b0ed9693195b49a7045a1c853cdc6152ed88e34b22cf7e83d6be47842bcee8f316e06874ab8bca2bff8e2ca0c204480e09f2fb9d1fd942f394ef0e9b7e8c9eb7381b982bb555f5b29bae8a20e2e81d0cf9e1112575842e96e3100d666fec55954e9751dad1ed472ec601347b2db4996c62077ce93afd14b5c7232ba6c7959c20ee066943cb355a5844fbf92d03edf66a80cc3345f335d8d88dee17a516cd50f270a39d2860b950099e15d0470ce4ada96b4c22348ace8de9cbbb0bee535fe6faca66eefc05021fdea502fc26ef991c817eb71e67a3daebab0b8fe604265461973d191bffa448582567086f11728fd05880cf2507c9e11da3639c6b9cb3611faee86787f39c67b2e2b1fb604be6cac0b6ec3bfde3f7b14b2d2703db86a4c56494d1ff5852c4af7b1a687ae8c847812e3768b56f67fcecb8934874c0c4dec1063b475dfbfea6e80046da871c77d3ec8249494e0b874b719ede55a081c8e30601b2ae968f813a577b2495ce72e68ece80a58ed2a7163c7c05c078d06a8ffdc979b095a57d470ccd549c8bc7c11ee615a8c71983b3b38eba46f7ca22598e5532162453c00f0da037f078d1f9bbc2e8a789aaa758a816e4885c890f91d7f5d5e3ec261db38a7c3c4bd888acd66db6e5560e9fcb45ced4d8d8d2329b53672114fe7f5995a68f40584812edf27f32efc3ea531042376a4759a267999a89140e605c3797b290423d472b6487d7ade10d604fdce71fef533faf58126812de2bd882f78d5bef88a8f9e7483d1a3c4c3ebe7a7f05a1a6c0b679a0a71cdaea3d9143a41f7a2a48fa805f784001028f40be5dda2a63e1bc29bb0d5cf69014e0f1e7c7fc128259b2d629973f62de40fc04e443074f7d8db6e7e4e6f8d808a15f3cc0bdf56b87162f63007c47efba7e59612ac2386d46ab92599423df0d6fd5cf15aac417b3c8424f6f9f0471b8c0e1d3fbbdf4246e7c3f9cb448e09f44018d0e3bd240ed4eef4bdc9c9c76ddf00b89373bb809892556ab570f0a26f15003bddcedb0b3a65c84f4d9890cc817bdd7cd8dfdaacabbb25b14380804bdf127f7e4ffed3dac0c59fac4d5f42bebf31d1936f03e035fcfc3b0741a7ef0b728fcef508ec746204dbf402b95be31050ead8852a0023c70da70492e06c56c41b47103c5506e7df3e85d8375088cb4fe771d623dc06dea26945778c4e9e4f3e5c5bc73ab24c083066203c168e22ea722db71ee5cb60e3f1d9db05230b53274dbc3cdfcadc33ffc35e5857038ff5d9cd3609daedb262017f85268a30ae37875cc59a18c2677fe679137ad2776d0db4eee9385d33e1340a4391954dd134b06f0e030eb16171e1e09dfc928b9b4ad713a43038851531466da10fad959d48aa112c853bf72e14276232fbbb7da2298000ed5f80403270886d7fc22c0c557adfe2bb32c0ef8eb6994fa0ed9fe63184212e802a510afccae304eb093c0897790706c89ec8c0d9746423694263e5b690ab2bdab8bd40d914dd63397f5e56b43aacee54dbe06ab5a371fe49a04fa1928726adf860ee2b26e759a3f38c82a9251780bc38989d50e3fc70f5b9300650988af231400969e04c6f3cc71069554ed73acb310cb16ca1b4b8da67c3f2ca03d4a07008c0bdaf9288f2fde1c3aedbbac5322f051bc50c0618811f999a93b45bc060968f1d2aa8c78948944a8501f0a73fcac6259c2e2c71ceb9773c8dfca95722d5e198a806c59fd4f8d66db386bbd5dd176f0c179241f031d34fcf5747b1b84a21551a773d734bfe4fac21aa0d3d9ed25ab02f364a23e2a4574d81c390366d6af556d7dfb646699727aa1648c175cb90dc481cea9420bb97b14ab47ba1399457f7e34185b6fe5e4f49c873eba404dfe9a791e935db044cb40ef214951385873ef28bc4b2641d2e6c4357c3d785db3cb099baf736058fefa83b1ec1148eb16fd4bb02f8368594eeb5fbaf66200d0992ddc2d7077169ff96910cd81201fb15c2e6d7150747900eeb7e6055f9be8d5728c11d52c087994f827b45a152f902c1170a1cf81ebb4369f6c1733c678368d0e21f28efc246e8491e85326c302f5738971307f944986713374d26ce480e788f5c3104c6b09764f7ecd36a60c9835b62fa25674cc4de43ff768798dbfc4eb02bf49ad32398e4960fffdd7040faf012dc43d8a4c9e0761dde6e87e96e1c29a9e7956a74765a6a5dae63651d9318768625c76fca06f5250b2e8da82f07a34a1d42063df3f6aaa97e6b2dbebd059a02d842d87b648cbc1bb9d0f144ff6b807da30ca885553fa44b69dd9d6e73054d84dea0ae993f9327934d206af59fb71e695ebfc44556d889ca5f5f57a4aa3151daae9063944dcdbb52ef19e47aa7b6bfbc09cb55c2c2d0be6bd22478e249adac7ad327028eccf89dd7013e41d09cb41c8ef90e34cefeec9b978cccddcb344f4c80333bc9713879408e42f8386fc6959293b3380cf251fde90bd51ace7cfa3f8747b365f537c4c3d9edacb63eda54b90a2c6528362cd5f682595ce0307502a541c8fe9d920dc50b033d173eba1937a8e60c8ea45f9574ba856f93e2e28209aa032b931068ff1d2647b28bf6ba14bdcdd6b624efb5d816fb3f44116ad8388b44489cf62ff7328705a55b6a554220c40f9b98b3f9d21a8aa39a5d82b468ec35d837eddbefb68fb2e14c8aef4ea13fd4d8aecaced2b07592e8023d66bf964b463373a09e4a81e7f41e48bbb7b7114d4a43974fa3676b32edb3df5dd826463714369ad882d088fbb99cf4f5bf2667d9f548cd47ed68141fd9be7d3869aed23023bb6061dd29d12e2bd6bef21a2afdf3c1ffd232dbcefc177735ceb53107a833a996856551ceec193dd2d840b11168fe2e8d03564e7180a01cb3419d88f26dc067d5d94375e784a6b286e3570dd7c6ceaf953625a07864ce2796ad8f16ca26fd387d9316d5ab31cb915240f0d89c6096d9d33762c82fe07e2790cd945b5406bcce8375d16db3baf24b326f08d53de62222b9598fe1b200e3f4188718ba58308624c6d6d6227359323afd41902ebe436f112285db7b6340e3ce0877432209ad550842e95d07dfba0e5e4c586de7944c2a5d4eb547839f8989a966e9d4d7aecba26f21d2bb6877a54a7254a5ae8e84f2a62f562b9d67648b4c000254265ad67ae05c7c352ec2c359b8940bf3fac4719e3acae6650ae72ad1951dac21c761373cccc7431abe8bf5e97fbfbdbe520582db7996cf9de0dc0aa8668fa4c306a55e40d0b952ded87cf23e3761aa1d0368738dea27d9eb2ce21057d110a572a22c05a118b942cd306813b57705484c8aef3ff22d896226886ac6c2671a1bb4d1da25afd018a75f9c2adcf668d232e854243819b3f174244462886faa90908cdee430c290ff0c95416a833d7b70da7e2825b7b364752e6535faf2aedc25267f2d25cc805b2f83fd65dc5ec6c87621de644593f77fb95cd6d387d05c4afc00e1692c2477d851c29a9b410f881f9e577ee61b9ebd15e759cb78e403d72d28d222042e2066957e0b27fde40381cfce7edfa117b11ba35b44761ae7e31e9493785fbbe505e4bed3210d3248c09ac0085007e0ba8d69288b73be154622ccb7b8c73e31305a64d4aa59d06586d9dd9bc202f57d093e350d9d063b549e78a384c8edf316f43080b9deb7e403f0d4d0f74c127f55e686c7043b7654c4ab8e2d15fd174b2669c46ac11f4a048cd7f14cafced5f9e56dff4dbb1e220701730667504a66654c8b2991cef8ee2" }, { "category": "range_count", "entrypoint": "bundle", "expected_failure": "noncontiguous_sequence", "id": "bundle-sequence-gap", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a24766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf7265cc4c59fd270077a5f6de0c36a166fe4aada8188c8f141c5ff7c2cd29cbe898664343771365f86b22525bc02a2611faf0b1cbaac8de6d064109d111d241bd2bcecb7ae22f368e2c55e550f73e4cecd2e75a5c88bba32f604c54907a3c99dab53951eafd22e8e81c84801873b2f64d9a4f701aca60efa607b52612ac2e35f93d72f311ada81dcd4761b3adadb7aa32d6dcaef85fd1f2418284428d875a83bb40063a45d8428ad4e66c212738ede1a55ae50d4b5d877e0e74e323cd8ea71e88e46330d36d98c38615d37291a178ec43be46a7ad5afe326be8c6c7dd9400d5b416f014bd33070e28d582d0526833356cc3b131f7843418d8767b57630807ee2f93df999f849e0f1b7a814f2d69df62aacadfc901852adeedf690ed439835d03a0295d68acce3a5b73dc67928d87f78cb5547dba39358643bcbc5461afb2209eb78bdc616e378483c1a4cb924ee5dc5fdac628a94e0fc42f6ac21b7b221e255fe46f3d12580a8e4d73c7e6c5f98c1eeac64d6e90d5959d3be5824811d3834a32793b655d1f3d4efa7322a6f0505fc7678d4e3aa782854c02bb9236b114ea5d56dbd3c2f213b71054401e511b3f6bcc527a4173372c5e7f453577158f8b6727843be78874a3d820f0bb59f1ce84de513706d3d739d5f18294b8e85868011764f86d8c822f05b2a8aef6ee1ff6a3887c2dee8769e6aa9f3ca134b9c6eb24f4c3a6e1f27116c64f0cba8203337a1fb2ed9520c878635f050a78a106f4d4b6f89f9b949cd91f58c214fb2f43dfbe75d2addb446da21c4dde2d4c3ec70c3986226cacb08f0a8320bb154f957f128d77ed69bab4b7fc29614ee58a4126897ae6dcef02b2f407bc5b69fa4cac05e28de0dc8095b15495e4d02f1143ea527b8c72240d09c74b41c18ae4183d925d4ccdf5f98f80fbb05c403cfbfc6dfbb3ba434be4c04fa5f78a9715c9191feb565694864fdb7208a5751670e811e343986179a6bb0b2d9e6304eeb5e67a249e0d72b7b8b37d54fbd2df49bc4269c2c1d0791a6f2ef357ede1ef8aa8ed64cf4b76e2ea24068ffc64f1ccb7e85aedc2054f3e6f8205e104b2f3c2b4a5b62bae72313ea9d5952e28a28a84a7cf060dac5ce8bf6dc3b7abda7f657be4f9b5f361ceec578958c14e0ea99acea74007fc98ffd9747408ba18f4b4c3d0ca878963b071d34a82a52382c9bdadaf23d92f88cd6d00ddebda130e8146b06a512021f9ed509d2018d2ec7856b5b7db4c5b34b6a7fca9132eb1d20852c92a49f3431532b78e40ee38f83c2fd494f9136809e656cbb6f38a663acab67c32e4898c401e243224fad4265144010efe5b3aed6ca4353f4004b822335a8b4c25bd12bfeb1badbd4d7a8ee58922223c41ab766c50b016d7cee49d7e0d09dba4727f725e46ec9e7691de6ab299b831e9222f8da442704d963227841bb30f884af5335bc90f9e39a374ecd0512f5321093c9baf5ca758f50c477e104e2885c21a2e3586d422466574ca3bb81afa9524c9c0dc53278a9d4b3769fca5a0b2493cd961ff3898211ecbc02dfc5785745dc0110daaba980878a7bb77cf959a6f76c84a8c7476c5e88949465fe3906e06594c9f9460f72acf9eeccf6b55148783eca5b4349fd8a03c724184fc2867f33d6f7389a1952b4d11cb9817b424ed8d08c87f24fd603e6e666475e91486e9b38e71390ee6383e9fee304678f2237f0b13438696ed6fc10033b195f3a4a50efbf5ddabdafcaca174ad4a63377e52edb15b64ef1213ffc1a2db18083639124a3f6f0e035eac668548a4a5707c3a28d1c1abd91708238e65b319ac819f866be37f88bde4e72c5847024975a1bafdc8d5e9cd7d05c5c684e55f2fc95bed9ef0ad8dbc67d4b230bdd7d2ed668adbce93a5504e17e44b1f66bcaa8097d5ee99c2f222ab86b45f86f05db2c63a63e38c98d341ef43400b12fd5fb7b65bcbd30398d12bc2d042a292891f8662ea5d9ebcfb335af434109a5e5ff9edbeccafed74ded1809d0bd1ca5c5cf74918665961beed46c5d8a16f97b95fb842910c1e3a0253c124002ae6541e2fe4b52797b08edeac24ba12629ebc382b56553a9b733225ebfc3236641747c5a86548121a43bc1bef692365955390e26c0963362799276daedb377fe4f69dcc6e8a89775814d4cc9bf8fb984b0c1b8bbb444db1e218140e2b7d98e4bec0bd06b16a3521ef42b534eae8f0327ee7270b03add203eb7b1289e4d0eda598abcfd88f5c1363d75b9292dd35bda76bdc2427374be10795542b441011c46180f63c48916ec76723428a9d246daca3f5e4b49f2dadb02245f2fbb90d9fd417f6955f6da4e978faccad2052ec28864033886e37ee280be4d307e6279e041434a82dcd0315ac8727c6844f549ac75c37fd2be756f6f9c4d8a745ce12eefab967e19d7f132ed1651506c50a7978aa4797f88fe35af7edb6e0701e1973b560e010f6a76ba7b16637279b303564d238ce486b98dd03430381d26ddc16d04ce841e08e41c81cf6d8a425b306bc74b4a3f47dee6758e21e86fe788d814eb28a9037f0e6ebec2e6d0463dc0616c9032c99385094cf4b168ff545c6e06d7e0160d22dff6aae7aca11b0849293c9a6497f19734a25c88b9961ccecd9e7081293c4b287c8e676720c38c1b8356ac8f1a9873a2e2470e53e959aec53fe3b0ba5cdd00d219419314940587d9781bb1ebe4e74183f89296487ddd4f390daf4b4a6c5963a593e6f6fee0c84d323a193e22a1494b8878d45f7922f6797dce1534e4d54f8aad13ed4a93c5f7c696f68cf830c2af158419337d973c109fcf94437fdffc6b30f24cec99753ec3c59842cc6b8803acfb5df83aa1b7ab59c5ab84381c720b97c2ddb95afc6f7eaeb2f4a6ecc91324ceccfb4e3e856d7eddf2cd1c26d7fb70d86f68241a2305ba1906d3bde67f6adc54f6780373e20dbd583d400d68f00937c4187bf4582964203b058f388a1c4355ece2a68aee6ab9fd711c5d3e58b1b6a3e274ee98d85e93f4af5683cb579fb639033d2cb955567d5334bb64544346fb6ef80ca709acab6ca93bff25a5d26405a1cd73dc33900db98bf0b9ecd5fada36c20c22b49ea7d261ba10ce49e79257b4722cbc09507811df74ee75b2bdeadff134c5c9cb821afbb1fbbfb6d718f66c14c141c8a0d71ec7d95b740076c8b9c4930991c180d059c48a1cf310e0a5e20946a8fdc083bfedd5e6b27939fd7131ed7ba9e5d4c33d55cc9c7c03761d8f2f5780161dd6b07305c72e2adb81f16d6f87649b2328a028ba503c3dbacaa2b54c13f3d11787fc63ec30f66780214fd585b6cc9e85be38d9ac8882c3d65c1b041a242b8fd6a5fb44814b544474d50374e9000f88cede20f4078e804c4aa3d821fc181b9d120c1af6464c888ecef35537054decc244dab0f4af7039c9235691951c156edabbfb46b1e07ff8f2b32601d2f280e0a123db57ad227b86912d1de156581a90d2821fe9171139845dccec984bd0cd07bc7007dbb25a4050e74b5e71b5d06903f9e5dc1ada96b5b01b223aae50a5b12371110fae38e045986ca7693029ac2b418e2fe38c7848ac019b6d7915ffbdd290de6bed90ba49b0f4aa478c2e6a60848068d6b187383555a81923772fb77de2948f9e0dc178b17e95ac78b7c2fbacb2afe309f51a7a4c93291dbd875ee5d84a6a02bb7ece82376cc0f095c18fbc42a25a7a0c805e06d907911aaa3d43d6c9d905da7919bc810d6f96a6c63e621aa4740c79ec50e4c371a5326fe39b5f3fb2b480f036de5c1afd89d41eb74ef829afa0a0b54d5b80149b1d4bdc6339f406dc650f107b0c72b94a9c218e0641ca5becc82944d508e712e62719f75d0f206479fa58e5efcbbf0921fcb1d38056bc82de00bbf0f3bc220d955ad582ae8afa9ad714f397b7195829b00882f0ad05fedd07038c1c1345f843cf1c0a76b4949ef1823254b30611dd1172aeaae45aeb3a025218acecd53a3a2992df5febb36347bdbfc9b07685964f099ff4bb302bbfa6861a1c6b2d31ec074cfc4e9e26b51771f3557f40d50f744c9471b128bb03a1fc4c8a3b77c1d21cc5013110e1068e8085a80997164e6bd4e21468c8da8a964b3afca402d2c1c395d23dcf5d16e5771b42bd9e83a06abaf3a53f8cb0c23ddfe87029f9323c2b0ebedefb859d0df72b203da6c80e306b6dbbf567df251ed6fa68a1bd5be59332e0381fd00927b1142d219f74d9b152e7d1297c90b1b4d8f90abbf3b62cbd39e6a4e089b35427f50ea2427c7c1697f3ab397f5732cb194be2c9768e0e1d5b39a7bd62881a8efb1e3b68f91a779710a305fac7dd5a1351be53f872cf16b5910b9ca3a847cf33e44af4f36007b0d4a83fac8c40dc844738d294a09ecb2880f72e83a67cd94883dc118e4f2060fc74c05f1b332c0a902efff60925fe572bd52b425296f2b8a260dccada1b85e57ebb63e4aaff65037dd86142bf6e2cc9ed25472bfed13f1ea62401ad8284147b82d2b31f122d1fd5950cc415b8bff56c6ad39aec9e104eb0b74262691f761c114d467fe51158e17b94a595f773782d0a5f6980c84400bd8c3ef974cea8ad90571efc77be65d1261a383289a04bd294e95203db5ddf86c5503fb2001ecf79c16b2797b15d38ba0bf666a6993c6ff84cac74a864f8d9c1e31f8daae3245e5585e356b7f9350ea3" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a24766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf7265cc4c59fd270077a5f6de0c36a166fe4aada8188c8f141c5ff7c2cd29cbe898664343771365f86b22525bc02a2611faf0b1cbaac8de6d064109d111d241bd2bcecb7ae22f368e2c55e550f73e4cecd2e75a5c88bba32f604c54907a3c99dab53951eafd22e8e81c84801873b2f64d9a4f701aca60efa607b52612ac2e35f93d72f311ada81dcd4761b3adadb7aa32d6dcaef85fd1f2418284428d875a83bb40063a45d8428ad4e66c212738ede1a55ae50d4b5d877e0e74e323cd8ea71e88e46330d36d98c38615d37291a178ec43be46a7ad5afe326be8c6c7dd9400d5b416f014bd33070e28d582d0526833356cc3b131f7843418d8767b57630807ee2f93df999f849e0f1b7a814f2d69df62aacadfc901852adeedf690ed439835d03a0295d68acce3a5b73dc67928d87f78cb5547dba39358643bcbc5461afb2209eb78bdc616e378483c1a4cb924ee5dc5fdac628a94e0fc42f6ac21b7b221e255fe46f3d12580a8e4d73c7e6c5f98c1eeac64d6e90d5959d3be5824811d3834a32793b655d1f3d4efa7322a6f0505fc7678d4e3aa782854c02bb9236b114ea5d56dbd3c2f213b71054401e511b3f6bcc527a4173372c5e7f453577158f8b6727843be78874a3d820f0bb59f1ce84de513706d3d739d5f18294b8e85868011764f86d8c822f05b2a8aef6ee1ff6a3887c2dee8769e6aa9f3ca134b9c6eb24f4c3a6e1f27116c64f0cba8203337a1fb2ed9520c878635f050a78a106f4d4b6f89f9b949cd91f58c214fb2f43dfbe75d2addb446da21c4dde2d4c3ec70c3986226cacb08f0a8320bb154f957f128d77ed69bab4b7fc29614ee58a4126897ae6dcef02b2f407bc5b69fa4cac05e28de0dc8095b15495e4d02f1143ea527b8c72240d09c74b41c18ae4183d925d4ccdf5f98f80fbb05c403cfbfc6dfbb3ba434be4c04fa5f78a9715c9191feb565694864fdb7208a5751670e811e343986179a6bb0b2d9e6304eeb5e67a249e0d72b7b8b37d54fbd2df49bc4269c2c1d0791a6f2ef357ede1ef8aa8ed64cf4b76e2ea24068ffc64f1ccb7e85aedc2054f3e6f8205e104b2f3c2b4a5b62bae72313ea9d5952e28a28a84a7cf060dac5ce8bf6dc3b7abda7f657be4f9b5f361ceec578958c14e0ea99acea74007fc98ffd9747408ba18f4b4c3d0ca878963b071d34a82a52382c9bdadaf23d92f88cd6d00ddebda130e8146b06a512021f9ed509d2018d2ec7856b5b7db4c5b34b6a7fca9132eb1d20852c92a49f3431532b78e40ee38f83c2fd494f9136809e656cbb6f38a663acab67c32e4898c401e243224fad4265144010efe5b3aed6ca4353f4004b822335a8b4c25bd12bfeb1badbd4d7a8ee58922223c41ab766c50b016d7cee49d7e0d09dba4727f725e46ec9e7691de6ab299b831e9222f8da442704d963227841bb30f884af5335bc90f9e39a374ecd0512f5321093c9baf5ca758f50c477e104e2885c21a2e3586d422466574ca3bb81afa9524c9c0dc53278a9d4b3769fca5a0b2493cd961ff3898211ecbc02dfc5785745dc0110daaba980878a7bb77cf959a6f76c84a8c7476c5e88949465fe3906e06594c9f9460f72acf9eeccf6b55148783eca5b4349fd8a03c724184fc2867f33d6f7389a1952b4d11cb9817b424ed8d08c87f24fd603e6e666475e91486e9b38e71390ee6383e9fee304678f2237f0b13438696ed6fc10033b195f3a4a50efbf5ddabdafcaca174ad4a63377e52edb15b64ef1213ffc1a2db18083639124a3f6f0e035eac668548a4a5707c3a28d1c1abd91708238e65b319ac819f866be37f88bde4e72c5847024975a1bafdc8d5e9cd7d05c5c684e55f2fc95bed9ef0ad8dbc67d4b230bdd7d2ed668adbce93a5504e17e44b1f66bcaa8097d5ee99c2f222ab86b45f86f05db2c63a63e38c98d341ef43400b12fd5fb7b65bcbd30398d12bc2d042a292891f8662ea5d9ebcfb335af434109a5e5ff9edbeccafed74ded1809d0bd1ca5c5cf74918665961beed46c5d8a16f97b95fb842910c1e3a0253c124002ae6541e2fe4b52797b08edeac24ba12629ebc382b56553a9b733225ebfc3236641747c5a86548121a43bc1bef692365955390e26c0963362799276daedb377fe4f69dcc6e8a89775814d4cc9bf8fb984b0c1b8bbb444db1e218140e2b7d98e4bec0bd06b16a3521ef42b534eae8f0327ee7270b03add203eb7b1289e4d0eda598abcfd88f5c1363d75b9292dd35bda76bdc2427374be10795542b441011c46180f63c48916ec76723428a9d246daca3f5e4b49f2dadb02245f2fbb90d9fd417f6955f6da4e978faccad2052ec28864033886e37ee280be4d307e6279e041434a82dcd0315ac8727c6844f549ac75c37fd2be756f6f9c4d8a745ce12eefab967e19d7f132ed1651506c50a7978aa4797f88fe35af7edb6e0701e1973b560e010f6a76ba7b16637279b303564d238ce486b98dd03430381d26ddc16d04ce841e08e41c81cf6d8a425b306bc74b4a3f47dee6758e21e86fe788d814eb28a9037f0e6ebec2e6d0463dc0616c9032c99385094cf4b168ff545c6e06d7e0160d22dff6aae7aca11b0849293c9a6497f19734a25c88b9961ccecd9e7081293c4b287c8e676720c38c1b8356ac8f1a9873a2e2470e53e959aec53fe3b0ba5cdd00d219419314940587d9781bb1ebe4e74183f89296487ddd4f390daf4b4a6c5963a593e6f6fee0c84d323a193e22a1494b8878d45f7922f6797dce1534e4d54f8aad13ed4a93c5f7c696f68cf830c2af158419337d973c109fcf94437fdffc6b30f24cec99753ec3c59842cc6b8803acfb5df83aa1b7ab59c5ab84381c720b97c2ddb95afc6f7eaeb2f4a6ecc91324ceccfb4e3e856d7eddf2cd1c26d7fb70d86f68241a2305ba1906d3bde67f6adc54f6780373e20dbd583d400d68f00937c4187bf4582964203b058f388a1c4355ece2a68aee6ab9fd711c5d3e58b1b6a3e274ee98d85e93f4af5683cb579fb639033d2cb955567d5334bb64544346fb6ef80ca709acab6ca93bff25a5d26405a1cd73dc33900db98bf0b9ecd5fada36c20c22b49ea7d261ba10ce49e79257b4722cbc09507811df74ee75b2bdeadff134c5c9cb821afbb1fbbfb6d718f4780fdc4dc2c5d91091df487b1d6cd5a4c29214c0d9e1f000809e01f15ff8bd890d5bb7e0c692be8bdbe8e26e3def6d30a163b184d8c2355ac28e7b106300892e468c0c13dfbf6604c03930d292e6766088689a4526ae7ea754347aa28bfbf54113e8fa3b91ea63f636f756b62c09cd9df6c19e9ee763caaa9f99de8652154b18252a94cda4fc4ebd6049407cef327be60113d9d6c633f5079c8e421fb5d521cf3306991a3630f75170ba8acbf3502d5455f4d145daa2abfe7e6fd26e4e89f4101466d5b5e34fa2f764f7fdbf27678da3d7b9e537c655e23a63e79d2c15ee58401383c59910e61b105f8a539cfb9c59cccc4ba46866d7b347565bfd405366a6cf6511f1f5c30fd19db4d6152c6cb354adf63b30484fa338fb6eb47ab1692a2fad1a778369fb943845ac1ac2366a13e8a6cfc0d065a292bb48abefab408612417d808852e7be88392904b00133752fa573ecc199930df253ac7a9d9652b6d5c79eb6aae30cef4b6254832b1dafd80feb8e51271aa31fc2832a7acbea9ece9ab748af5d7369df0f5180438507a8725bcea8d504c76f12a68a027a9daccb396b7faa7a5c6ee842f8c23dbd2a0eef9a4229b9ae8bee34de5a0ff998d578b940ae34aba8e7ad0c02c0199b0660f0752ff41cda643e26764a6aa10a9121955f1ad95dfbd33414db06a810e72602ec5c082a5852e75cedd5fab00620e0e0cb9847bf82cc0eb5a5e5b1223ebe47d18a94a0fb8deb26f792b71c4278a81093f3ad17a18c095597510b5d9b41cf1a1c76b89888e0833726ec5746845566b3a8ab42f35900521ebaecd9362c3893da2de5bd3350b9add5b13a9d8e2c0b9ff2ad3027b0a49119166e233ef00c43ed5c9e30b4717ffd036244dd486c0dcd31bc28a0288dea5a8a2171c0e32ac8472908a10b8e9bdcf31a9101557684ec1a27cadb8b8d503bfbae3ef9dcc79de80cc05217f926035aab9a83b264b4a6b3328c83e920dbe04a03f82500190abbdefe9fcc15ef3d243db4975f3e3d76f6ed7faf321ad3e713feec0cbcd726fd3a50c81157a610283ce47ad7e145e3c3357ddca9acaae819bdeebc26a428e9beeb85b44134f31fa3247272408af7a37e67166b8b144bf9e25a98181d413fa68c54855c96e35e3668e2432c8416b41ee797d3545c53bf52e369ce11bfad2081a7a07dfe3ceb4be8a2781fa7d1a82da28215ca89470bf989a496f10281e012b1a279d94d998d0996582560ee2b91514d286112885ef8ed0922f95e4e8a7a645cd2f5a7a32d878ced2a94df5f874bb3e1b5481579da7b49f3376bb992562240d1b859249f172d9aa5b22f69aaf5bf2328096294de14c111aebff5357981c5b3da6d11f7a25e796b105d574f4d0c3cf50a5ef63392b3dbf179796a470f67878b4901bc863bff06ccf0e2d92a00ef76b56390794a2938c2b20a92dffb5d1be874ce9ddb5c0fa95647b2238a2b698eae1e76fe56b43bb4816322b45cb139fb228ae5e933817eb74116c6c2d96cc7cc4c6d517f9e1e7969e40efdca0ab9277fa062dd4032e9e9b7e85a14685787faa68c5a482be3d753031363ddf1fcaa89258b122c6e59469af4a03211139ef0876d9ce7afe17920cfbb50b69b487dad4d9380a5c0df99e58c20c8d663cef10442b88a579d0aebd6846655c757043d9e7b615c33c25efbf020f84d5b8f0612f69af51bc28395dd6b1b49d193cec66708796da52ed369d30d790ff24f702ad65813de21137c185b226b309118e2d9" }, { "category": "catalog_contract", "entrypoint": "bundle", "expected_failure": "unknown_collector", "id": "bundle-unknown-collector", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a25766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf78464c7153c7c48c3856b8fd320e3d5a8ee49bdeaae658e489f659905b029bac3ef90239df8e881680e8dc6d07b1d48a21f5af4641df79d598cb5ad33596169d1588cd687dfb3b31c6945bad09f76ed14afb54f89c8f2c2fbc34124529f5f5ce1c810d65d8f52b38f9befb1f538bce34529e58b94a6344cb2a89cc0e8acbb3abe280b696ca457104e2afcd960624984c42ae0802a51042984eb198311a8c57ab79c6fa290f710ab98641d71be13eb63d4491580680f37d1159b9fc596f7efe42ace1966d47f22b6dd8f171df445ce02d485992d186b12518b98bbd423898af72266131d46402c04d9c312c9f624959fb8d1b5efea022a96be64c1dfdafb916db81d03d1ef13a0b1a7db4b15e634eb560e3fb85bdec4177c7bc84ecd7c2c01d061028544bb85f75d20d4d680e425f05dd2006e569e69db477737bbc4f6a43ebf7514aa220d92751ad9c3674aa444d72f625179c346f613f63b133785bc619bd46656a1b2e5d8d3f3d02aee0c1431995d7ed8624b8563f2f4af617d73cf27f4e62a83b6eb574a23b8735ee035fbbe90c9d80e383f03b8ec789ac95ef2ff2fc98b974dd411a6ba2b37b09bc01203b4eae7d69e87e85aeb6b09889ad80ca4f0d2769311f297318a339f003f2f1fc098d3af3c7aa7a9a329129e288bbbd293006558bf5bfbf49e4d15347005afda04a6205b63313653b6f035dd0207638145fe6e9642b5a0eaff0e4c57f0b0700d823dfb5cc2b8c13869d7e9b49d5ccad4f2bff56d631a1d5c28bf92b2fe286c878f1d9a8355f9ef5bfa90433bf901500c2926e09e5b73fd99a3d2e7f5582c0c8503c35276a0bac26e3b8fa4a0b95ec9b311e1a7e54e7cdafbd0b450e2a55b3923878654e135d71f620c42fe7adf211c743a095866f328196b8b49eacbe944ce122a258558fff7e6be14e8e7540068fc7ac24efffcb06b5b7be10adf5968ec2da9e51a26934e2f7dd753b225544bc22cdd5b5ee5b6c028f8547028e3bb5045080743f045c3c250328e0a0a027386e11adbe70e724542afd125df7cdb602059548596e2e69f279f038f7a25754aefddf2f09f5055902c21074bbdd4ff973ad252806488f7ac2681d9496f49c4dc0a9ba7af3d044b8f28bafc7e9fa2fe3729af4123f2d59cda0674b5f533bdc83a2ac3040c29f0be70641bbc4a8832052193f64149c33c7cdeab78dbbcc5d00ba6a65d2a0ea55092bd4833c2a5a42dd6046e9431d4878da14ab9d27c092f18442b3aef76ccc293e01db028c27d7948b7d5d27e5a2b4cd1b1b5f26b2690f0c2c7bf874ca078fe5b28b3a671adaec78f3b7b2599543428f7da09d1ff0d1c9d65f60a63d53c0dd98672dd1f4dba5129d5d5e07e40b0782049f97cc1d8dd1256cd208cb323bab4811e00ea3184b7b401f4afc45bedf788b4979dadce3a06f01eecb8a2884f6427f67a03a9e8512864349aa9c39e2e08ff2079f00b0cd5527f26fb0303d4c75abc953c8a508d0db6ff2f384b0964ccf4ec4eba595040195a5cb0df7ac0f4f82508c46c071204d9b8d53d4b190e16b308a6021476a8f752b5e95e1aea7610a9f1c167b0779c5f0a4f1f5170ac58829a75a8724edce84d30e5d5ae342937235b2557ff00ecacfe7ddc8bd8b3e534c03bfd168807b0d2cb5f02086334441ac0dd39653eddd0c468f42e3074a7dda71f06dd51f9f32216f35ca4141201ffa4da876f1ab9b6cdeb4c8903dc7eadca64cf42ac1b5e4884a13121f79fa03e142b3431b5b9184ba5bbea1e442f187bdd9064dde476c17410f6fc8f7f5627a0073dd5dc3dae8edfd109da72445047f98076790bcd4862fdf61573f66d9ce895ca545fc6ce0de4091eff008c82884627abf6b16b505efd4b0c1007505f17ce094f6ff78e5eb728b858362f216e3dd9ad2ded2f5552e2c264e23cb3f99e5b93eac7a886c2c0fa53bbbbbc2cd826f9d6538d4a54b4cbd4e3e7ee70b6d1843273a860e3a09d197a350ad806eeb59c4211bcaa4f3f8c72a19abe660797c25f849da32c265052ea5eab9723461cb737ecbff311741473cd947707b0c7caf8cfeec376e5d65ac1d4debc3523913b714af677b2728003d499ba6aec9538975c4099a16d8cb940bcd35c620f8bf59b0294cc097a046d3fe99dc15d9ef50ce12ae932a78d43c25fe9644625308b8ac487690a1a5c31de63dbd322e2d69dbf03f05d206fe297b9ab997467e1a53eb24d3896cdd09a32b30e0628ea3fcaa45a4c28ddc8e1c46e9ec7814657e0b4e8522c74d234dca9c9346933fd387d81da01f17e59e7e0c809d68b33b3937a4ebf07704b579f088b1a5ed936dc82025fade38325534a32e287ca7d633744702e2ceb962ab3910f868d14536ac9f7f72d4d21ad9fd25da681a27e48462b5ee873bbf1dd93faa9eb34db8b6c5cc2be877e1dc72b2577dbb78fdddfb27f16f9cc4ed3eadbd65e30e2b27cfee802a3d1246e2ab4da4607d1d4589ba053c09a71c8f87d7f6506e89d29140f56c5ebd1302810231a33bc8b600e6a284617ee7f1fc240a5f4cb6ad5443043910cea2c7972d89eb6610a848ce081525afd47f2fa8992b9965dd62cf6a2ca19cdf81a0377654e2cbd16abcda69db32607e53c255458e24fcfc9e1dc69ac3b960a4418f6a9bafcfc6a643064812d087c9ae6a689276e4fbc4f2f689c839be6266e37b8d21468409e1e88b6d184af96f9c493d2757b4eb066a04685ae6d95cb8abd4657df06cda73f2259e777ed93601cd9baf17762121ad305d685207d67cfa8d7e354dff58043eed7e58a6a83c72ca2444f6a06539f31ac739cf51adeb353c3503728439169e5cc42d650b9d4511e39d21afbe897ff0282e0e321effbddb14256973c860ce6074ccbc63323c110287c8924da542d58bcdc6a4d0e0d197d3b669e12b0b7dbb19ea0101dbdde2d11054b7b0d1f8e596fe605bab648701a91dc3e5c04352feaf2e51bb6a8f97d9d8d30c8729e3f872d7a3e4ad94a8194662f5751b1c1a3b620085391eb73283a239c2ed47fdefe88b9ddca431213ba3f5c59e7dd4c285858b19eaebf4ba75b34f5f524cb258a3098bf10875fa7e34ef9cc02aed0c45eed29d638416512b1917a1ff8d184cffb226451f5e37dd5cd6fb98618e5e92701a9086d55acec2d10b60fc9da6fd00380e1cf6e1dc526f7c031487ca417280761d1dbb151749b4f05a72b12265e2131240cec19a8eb4e9ea6e389ab6767797856b0402d96c8680c4defa2790b38ca4212e1367a4e63bb604de85b44bef9cd9f51c265a3ecadf1c44ac2d815d6fa82390d510807a369f1d953f7e547d4a13022cdfddb91f6cbf803f4304c4399d0efcb4a7c1fab5dae82ac6c1e4cb9b13f73fde18ac758b39a9a207382146a74d9cbf2261b6f1f47200f1cc3ef84cc1ee5972644bbd6cca9280b1bd6f921b27e3ab5ad8e34aa5bd0159f2742784af56d2ec55f831cc9744abc65e7fcb025ddd5ebcaaa54e6e839bb8b5a6396045c539b6cfed00c5a65ebff0a63889534bbb71bea10a97f6f1289a9ad81a4ec3589357ffc0fb7b9f9142507440002ef1345833e978bd699afffb99ae010ab5bb2dea8105f57c1cf2cae4b6558599644504a468eb9da7607970b4af338bed0e9fb7225d26103f4bbebf40f23dea127d27a1b1d72a7fbb1869a41a31110f272ee1438b7f6ba953ab52093d8e0369157aa4bdeca935bcf2f63a35e54cae1d3f721989e6351e133cbfcb52d51b5a4906a2f652f70d581544dcd60b5c0e48d5a9b613b5a8b445d59be3e6a464aba1b65d7ed0415e18244ed976951b4a05c1edf7e9ebd9eabc63cd3f6f236e5fa2c954284e1d49f49b21769d1a5fcaaf0817a3f8970fe1012422f8ba0f2d38efaa0343e72c9a44537b88419de2f20252886e3bfffe81d8e591f0fb094f9c5f53937afc6d0d6bc848650b95413a116c1ec3708fc7025dee659778ca83120e84e55b01a17b331546ba24b8bb064f8c9cc5255970e1d3409ecde09fbd7bb2516a2656611decf8b2af5e1a72bcdaef879170d822db21a000a6aab65220a6998e284683eec946f9fc753ab467d9dcd4228ddba68e7ed48c8507155723d1c8f2a5f723b68fd4ba6a5e19bac7ee27f0b9fad61bd7fb38082e48b9e22e7115cd9fd15768b2d554cc43623076be22b5b0ebb085027c08a8ef5389a9378b85fc0c60a0a381b8ea074e94b61afc59c6c325aa096212e2e2aa0fe8ac0c02412ec75632b3edc267136cc7b2ec51a7b93ed4fb16a1bad45c83495d7dfecef27de28830b518e4ab79c6d925e136" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a25766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf78464c7153c7c48c3856b8fd320e3d5a8ee49bdeaae658e489f659905b029bac3ef90239df8e881680e8dc6d07b1d48a21f5af4641df79d598cb5ad33596169d1588cd687dfb3b31c6945bad09f76ed14afb54f89c8f2c2fbc34124529f5f5ce1c810d65d8f52b38f9befb1f538bce34529e58b94a6344cb2a89cc0e8acbb3abe280b696ca457104e2afcd960624984c42ae0802a51042984eb198311a8c57ab79c6fa290f710ab98641d71be13eb63d4491580680f37d1159b9fc596f7efe42ace1966d47f22b6dd8f171df445ce02d485992d186b12518b98bbd423898af72266131d46402c04d9c312c9f624959fb8d1b5efea022a96be64c1dfdafb916db81d03d1ef13a0b1a7db4b15e634eb560e3fb85bdec4177c7bc84ecd7c2c01d061028544bb85f75d20d4d680e425f05dd2006e569e69db477737bbc4f6a43ebf7514aa220d92751ad9c3674aa444d72f625179c346f613f63b133785bc619bd46656a1b2e5d8d3f3d02aee0c1431995d7ed8624b8563f2f4af617d73cf27f4e62a83b6eb574a23b8735ee035fbbe90c9d80e383f03b8ec789ac95ef2ff2fc98b974dd411a6ba2b37b09bc01203b4eae7d69e87e85aeb6b09889ad80ca4f0d2769311f297318a339f003f2f1fc098d3af3c7aa7a9a329129e288bbbd293006558bf5bfbf49e4d15347005afda04a6205b63313653b6f035dd0207638145fe6e9642b5a0eaff0e4c57f0b0700d823dfb5cc2b8c13869d7e9b49d5ccad4f2bff56d631a1d5c28bf92b2fe286c878f1d9a8355f9ef5bfa90433bf901500c2926e09e5b73fd99a3d2e7f5582c0c8503c35276a0bac26e3b8fa4a0b95ec9b311e1a7e54e7cdafbd0b450e2a55b3923878654e135d71f620c42fe7adf211c743a095866f328196b8b49eacbe944ce122a258558fff7e6be14e8e7540068fc7ac24efffcb06b5b7be10adf5968ec2da9e51a26934e2f7dd753b225544bc22cdd5b5ee5b6c028f8547028e3bb5045080743f045c3c250328e0a0a027386e11adbe70e724542afd125df7cdb602059548596e2e69f279f038f7a25754aefddf2f09f5055902c21074bbdd4ff973ad252806488f7ac2681d9496f49c4dc0a9ba7af3d044b8f28bafc7e9fa2fe3729af4123f2d59cda0674b5f533bdc83a2ac3040c29f0be70641bbc4a8832052193f64149c33c7cdeab78dbbcc5d00ba6a65d2a0ea55092bd4833c2a5a42dd6046e9431d4878da14ab9d27c092f18442b3aef76ccc293e01db028c27d7948b7d5d27e5a2b4cd1b1b5f26b2690f0c2c7bf874ca078fe5b28b3a671adaec78f3b7b2599543428f7da09d1ff0d1c9d65f60a63d53c0dd98672dd1f4dba5129d5d5e07e40b0782049f97cc1d8dd1256cd208cb323bab4811e00ea3184b7b401f4afc45bedf788b4979dadce3a06f01eecb8a2884f6427f67a03a9e8512864349aa9c39e2e08ff2079f00b0cd5527f26fb0303d4c75abc953c8a508d0db6ff2f384b0964ccf4ec4eba595040195a5cb0df7ac0f4f82508c46c071204d9b8d53d4b190e16b308a6021476a8f752b5e95e1aea7610a9f1c167b0779c5f0a4f1f5170ac58829a75a8724edce84d30e5d5ae342937235b2557ff00ecacfe7ddc8bd8b3e534c03bfd168807b0d2cb5f02086334441ac0dd39653eddd0c468f42e3074a7dda71f06dd51f9f32216f35ca4141201ffa4da876f1ab9b6cdeb4c8903dc7eadca64cf42ac1b5e4884a13121f79fa03e142b3431b5b9184ba5bbea1e442f187bdd9064dde476c17410f6fc8f7f5627a0073dd5dc3dae8edfd109da72445047f98076790bcd4862fdf61573f66d9ce895ca545fc6ce0de4091eff008c82884627abf6b16b505efd4b0c1007505f17ce094f6ff78e5eb728b858362f216e3dd9ad2ded2f5552e2c264e23cb3f99e5b93eac7a886c2c0fa53bbbbbc2cd826f9d6538d4a54b4cbd4e3e7ee70b6d1843273a860e3a09d197a350ad806eeb59c4211bcaa4f3f8c72a19abe660797c25f849da32c265052ea5eab9723461cb737ecbff311741473cd947707b0c7caf8cfeec376e5d65ac1d4debc3523913b714af677b2728003d499ba6aec9538975c4099a16d8cb940bcd35c620f8bf59b0294cc097a046d3fe99dc15d9ef50ce12ae932a78d43c25fe9644625308b8ac487690a1a5c31de63dbd322e2d69dbf03f05d206fe297b9ab997467e1a53eb24d3896cdd09a32b30e0628ea3fcaa45a4c28ddc8e1c46e9ec7814657e0b4e8522c74d234dca9c9346933fd387d81da01f17e59e7e0c809d68b33b3937a4ebf07704b579f088b1a5ed936dc82025fade38325534a32e287ca7d633744702e2ceb962ab3910f868d14536ac9f7f72d4d21ad9fd25da681a27e48462b5ee873bbf1dd93faa9eb34db8b6c5cc2be877e1dc72b2577dbb78fdddfb27f16f9cc4ed3eadbd65e30e2b27cfee802a3d1246e2ab4da4607d1d4589ba053c09a71c8f87d7f6506e89d29140f56c5ebd1302810231a33bc8b600e6a284617ee7f1fc240a5f4cb6ad5443043910cea2c7972d89eb6610a848ce081525afd47f2fa8992b9965dd62cf6a2ca19cdf81a0377654e2cbd16abcda69db32607e53c255458e24fcfc9e1dc69ac3b960a4418f6a9bafcfc6a643064812d087c9ae6a689276e4fbc4f2f689c839be6266e37b8d21468409e1e88b6d184af96f9c493d2757b4eb066a04685ae6d95cb8abd4657df06cda73f2259e777ed93601cd9baf17762123b92b40645862d872ac8b6d3a50e5451938ec6309bec91c2b8e3059f4ef7d21981eda26d257bcea53323b5365802b0a9f10dc3504079c4d1eed8f26bcab9479f13922143c17f0a8da1332737adb77d56f7bc0bd053c32471f83c0d555e41b9586cddd8ffcf6c797c9b068d01d063ba301aa0c01c084b9c21643acad81f6ebd9fc615ab07f8606a321e8f8c44b68cfa02150a73b978fcad9d31e8927b6ee7fd790cfb090a0236c63e24929181f3b671ad42106a43383b06693e311e6a2f093fcd0a5392e35bbfcd68966dbcd8984ed46bbbcadfe61ae4d104a54d354a20184ff107ce9692fe093cc2b8b024bbec5997198171d330176adfe900e47e5b92b520c413bcf54c6e48d6a855ff47e14c69bd15289da905220f19dbdd62c2e181cece7dd635971450c9f8a4c729b2f460ebd020c54e4fe543db32364f9081347c4fdb193b0e1d05f3795b76a268f9d7b0602cb6288d5d2d3fa14bbae88ac1b04127098d43fb304db9fe553f78cdbf50e790b309cc4515cb44c8d5c72be288ade1f95713c921a9c5a21052a13571631dd92a1070db38122550fde32921bf7beaac6f3d0d4e67ad1c5f6d79a5fef27bf14ad689d32b3a9193a2140b2498dbe446fb8a7e97608b6d47fa10cccee4259485dab6cd09481808b62d4033fa3a65ac3ba11b6bb1642ef24298ae054d3ed4ee330cb9d7880db5a77f13352d25fa0fbbd567f809baabba86c7648c50a9dd2e908ff8c5fa8cc943c8c534ea120a6b91b94f6e377cb948e0103db40f055ffc6ed7b939d544175457271a0630177fd65bf2682e7989bae071cb5b721fc9004f00e12fc9af3b2479998285d1cc76aeb9bb160757fb6b83081e80090ab295237023f5dbfd94efc6bf7167560b9f08e6a72bb0342880c271115212fd075863973b113a6521264d5106f0261b9ede2a77abef3f7212ee44ba421146f1d81dc04111c3da39a4acd0d5e4914acf807e10058266fc1d203662449c2958417b0a8b15f8483fbf0a064b9fee75328cb0c46005459dd738860155496b4b3fdf6dba5a47bbd286b267324acc7043f4a0f55f5d7396eef0b4cccb2021dbae99815ea0d2337ebbc1e2c5ee1a4555ee324dd5c1222c84c9df9d92e449e6e21f9ffb0eee8d7e8e349429c440ac869fa7a167698466b4497403b0a771fc47ab3ec1f59e65fa677c5820e5f9cfd4c04a1693d1b13ac29b8882d528894ff0f5867dde1449bcde585ec63aa466f26443e4ce2aea9e2460202bbc1ef809679bd7d8a07ae44a1b5b71f7be0aebf397ca2d2e111b2b6582cb065c3d7987bcaabd9fd1bbfa3ec612f6e10fdffd593cc319ea8d886507a64fb8cf622f4affad642c4a967576a35e6a53b6d0ecf90fa1c36b29f17dc5864273eb834fbb6e1b1c24f2c06afac5e88a83d8e838e0e38efeafca6f9064592f745ac48cc8837eb492900edfa9926f9b7120e7135910f4fe9a682290a7384fca80ce5e42ccca45bedaac911d00f2f41d62617a0d39ff7ecf13def3f21b9ecc862b1982ece949bfae8a2fc24217e1daa65cab5a1736702a5df1dc88010992c926d7dee23f1561d822566cc9a8bac0daa79f561dc9e318d98507a07495d1bfac980116efbc050b6f5643c514647c19a64e27f894ec04920a440fdc3da0ab853da00f74179eeee410bcdc6d557280b48d963e736c011f19f9dd04a721436fd5b3923072b96044e0e9f175f180d62a4bcb69a24827d82ed49e1cf686b6678e3df7840e114997b1511a51a" }, { "category": "unknown_payload", "entrypoint": "bundle", "expected_failure": "unknown_payload", "id": "bundle-unknown-payload", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a26766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf46535ae3f3a47b7f945ce019695439b27dec63b7ee3f62d73a072bb5810d2a1b2b0a08cb0a926afb6bef240fd3bfb13f2bdcbc49ba51e8ab2409f3a5112e7e020ae3ec95b2de6f5c85e0c463b7cc4c2b9c0f1bb119b43218be721bf06f5fee4b3dfb9d66160eedcec69e55cbbcf2ca49c50087ada866dee44910bd9af8af22df526ccae37077cfdffa95b00494b27503d2ee4c534ec9bce495de8bcb13f59be97e8f112799c896ae25ec335dd15acd3f1286e9608d41746276bce1e1194680cc6cb08989e4fdbd422eb366e7ffee4e0f322387889b196dae8e9e272af02face09368027e70d938f3a2fd5f9f571c15a6b47d078bbc0b7fc2db16fb36c92ffe2ee1df04a9e32e08e60de2d6fcfbf0a75ab9c39f37b79ee7a36154c8611b1581105c7f64eab7102f7044acbe3d9c93554881ecd4cac6c262f342bfb06ceceb502e79091dc4dc258e92a97ebf7044c935cd9da7d10da0448125f9f4778176c7e100cfff02e68e66bc0c637bc77410a0c95ec0304542f68fb2e99a901f252dd57de509ceea43703e6c9ac75ca9dc4fe53f9a43e1e9a5c76d70f11f0497a1d8cec720d2b1f1fe0a87aee07227afbce01792d84826ab1e89bda6bac1086c61abf551400a50ea8bcdd77bd1585fe25bbabcab6cdcec71b9208f342ecfdc5962a0105dd37c6c93195e7a4d094e48012f857a1f46fa3b084df4fbc84eba936e576d3ee46ad8bcbd4d88afe858a027a2e2e88e83f1eb461d5f75bd85229116fe0de838ccc041e601bdee5b0d27a363fb71e6b32d38ae8d91a5b3b5bbdc680bda0212e6a96e1ae56195a1e4437607a0a30b4a846658688113992e6477937df492cbc2725561002085873ac064309f52da09b1d731d126e16c3f4a541003cdd6189aa8a8b02e9a9d7e4fe7225fd66df0561137dfdbb9b5f1af9d0ead13a517f70ab13aca83e23302d81fcf6fe8f6f7cf5a0853882d2db4b51038b00b05a9ad1d937d28717dcea9fc20fe1d78b642360c9364b437cfa40fd29cc9664695ccbe21de102ffac43f2b823f2cba2a897ced75b22768d3d27068befd1e641e36d25677c9f717a8c5867e63861cfc08660963525650558aa8338540f3dc09c5c0b1bd419da262949d5a6e4bd6936459ae85a7194052633ae4951bcb2fe8ccf90b47376222ee1a48ea026303b9e0eadbd008dcd81588363eadcf67d6f7cabedec1a24aa9266dc0fbfa47709a7d8536ec137026bcfbd9af441606f7184cda8f37940edc3a9f530602940af1f1f60b9b436f8ae6bb3bfe9e9934b1271322e43f4d4d2b7d01da105174e583052f00f7f06ad2d5bb85832c4e0be495dd3830e482d696a8f6a66d5bbeb39a7aa6fde9feeb70274538712f948963e0a60f6cf9c642e51f5f75f1d29e092812c7eb87414319a89b085af19510fab237d97d10f0aece0fda403eb73ac10e740b34b0182c61b140f9aa8ffeb9e7460a3c230f8e28495543f372c29d4f3273a34e3c26ac7c79c02f62ff045b2b531a22c2df9705a722020d48bb72754677fdb2ed61db598a129f033166e201fc2c9a81ad177fe28616eb8698b61788f0fa6a369a6e5e5769dc5d515132d86549cab8cdd4be489a3e6cf35431b6e3355bad92e83df2bb8dcc28d96c4bedcd7b05f067be6c27e515f7f24808565f419d8e8fb28df6400851f3592bf32201988f34f5bf8e78734c75488a5a10d49a27892188da7734d317d8b07e10d10108f1113b3fdd7b2c3632624968dae745df194f86bea1ad7476148f3c223419d60932911d18e80732d9a09d271a5e3927b5c529e76b6f64a5510dfbd23d6be0303699c57f3ce69650a8fb1d6d6ee498a3ebae9b6d48f542fbd39ee186eada0c23ec5d53663988237b588b3f2a0aa607ce7ef7aa59f8771b39abc71b119fb831859a05a1b9b31ebb0a213cae01c08b2def4d537c7f7c3a1ac9f361cf1fe387618d1147cd164af9243e9c8c7681a55ec8f0a5e2f12e82fedabeb52f76b267fe18af96bea76b99398508db6e38a54dbdef1213862a2b9e90b0946dd7f5c8c865b6fee0d55970915ebdd2749d870ea64b0266828a56e224279e9bf20115f86392b02cec9275002bb7473bac4fa3c77a01e4857aaebfe44cb9ce8173cdd80f7adb6605c2ec42b8ec3c87e2fdb99193e237765fa3c5f63f1a1a27d524ffab16e1d166ce3e9b7a853381ccece84094458efc29e298726bb704add67b970ebf3a33c02bd245d9c226a31bbc325f567f8481d090ba65e2cfe5ef14d1f7ea3d6336149ac727510c8084755aea86f84b3ce152048fac40f9c7eafeeb2b747222bcc59d5f21b8c7993f8faf3d5bb275a743cd64f854f7353fd9dbcaa39884206966491db363b4f752a3fa17687e33979dc8634cbdb0645390a4d8a855991b0ed4f544acee793e56eb4ec37f8d0c8612bcf7631acd41f40d0f9628c06dce3c58f77b7993c70fab39bf5871f7da99b314d79d33a4f11bae25712ca0b8a0a351521e0843267426393abbecb12268d7a48fa8f3480bd6d64000a6a223462e50b26d8d8a210a1ac7034b5fd1360617eb908f15ba5f1ddfa5f77290951d09b35c18d92b04d8a445d42d209330b3115abb67b208f4b4b0977b39ed715d86b7b56d837cbfe23e0d15af3d95a1a2865d5c79a95bc5768438ec2c09d755726a8421317ac31cb713ef05863bf260113af3b94692652f82447a763f80e699f1ba3ca6830efe36f4b4aedf3c243a928019ea70aed6706411592dd67364b4291804f97b79cfbb2c4b8799936c8646e9dad8c8656229aee0ae6c8c18f4070e0521eb1f195da5a768c482e871e475d0e1c45ad5221b5e94274dded167f409562d9246e61c6dad3ee6a44c943942504186968be32cc603687de090e5235d71714f72e8415392d80d71bce4d288b0d3311f4acf3c70d5c23a0cdeb276d1156db299dbb8ab48cdb9c4e4583bab210c3bd73a1fa4ce42e9671a58783ae60da7da132a3017bdad2044241f23f226f4e91c0fdf1f94191cccb61c0211e7cb9a84f13fb8e3269da69c9c1a205db30fea62599e2bacf6d86bceea346f3dfdc66a6fa6373849bcfcb75c05312e488fed3fc49b38350854962982ad8ff54ecaf48388df4449074397c6a19805ec574e6af4c73517e5e85c20469a329c585e1037386a681931dae92effd0a7ac5339fc2c271e8b41e89b4b1dfabaf41dd4515bf1e8b0c34858901e2c4dbd79068b3159f97123e56e0ad5a0ab97680babfa7a6f6ba23c2995da9e44ffd671c71d17760e2c928c79d822956435e68c7f05afd78c379908c5f6cb7e7f21fa400c74c80b69242434a16fc5b70e023ea5228ccab2213eb56bb90d5935a2cce4b9f427f0cc86ab3f92024349b96d10294cd62ffb0540b7e50367cb45f43f3a8f76dd63c7c43076bf0332e5a18ccc53e84c1c07bbc5b6a8b13b83513f9cf17a368cd7d04549198b3fb868c619811bc24e83cf0fc8fea70b37dd40bd80dbd95af112d91e832a68f9026c50b099c407392f938183ddfc031a765b81482c73abf8d3a5c1556b051efd4d4846e45a99cf07a017658e6ee208b7d5a93ad4edf87abb4b6fcc5c1ee02ec24e2ee36879064386842ce18fbcfa63f24dd22f45d69cf956975b7e65cdec04ca716270e312ce91b8323399d91ff368587c065655359f789bbbb7573df420841de607b1ae2bdd25c40a1828b43628ad59fce699bf0534c3e4ce8802f0055e4341f17dc18c9094e29b930f1732571ffeef5c1cea2de89b131ff49137f13c3eda9d3becc1d7a83eeec8d7c3c5af5a92f898459858f954fa8a46adac6ee1be31ddb72c5668ba8feeac6393a3ead3109e1a43836441f87b32cadf7abab2173625afdb6a5a66e129da8bc26069302ff418f2f1f69b1addd5903fb34f65e0404213cf087cffced80ae32fa5cb4522c713823685df5368865f1d9b199e92fad8677fa090624764bec989cb11de8d6a96c80c3bd80db55878bd02a9daf038b2ada5648548dd0bef1497cc0f13a7a8e5c5c315689f0103acc2bccae1d21523fb8c9d02188e4f96c341194c4898e4264bd4c859c50a8bce264002117e206aa95f0430048cd94636dda4305b859bc621f9cd22b4d88ae8badfec928f1ebd8459d3cfec3ce98d9cec9a813395a62480a905f81b5772579e80f3e8aa7390ec465eb7d96111ec228253ff26c5c026999f611e3eade4bed9804a2c074e6b3d9c1b2c354e995b3012e09b5a1c40e562155b20ef38ebff775d8dc2913bee80167175c53ceec19ea014136335680ac76b57cf0cb16a5273e4a8f2" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a26766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf46535ae3f3a47b7f945ce019695439b27dec63b7ee3f62d73a072bb5810d2a1b2b0a08cb0a926afb6bef240fd3bfb13f2bdcbc49ba51e8ab2409f3a5112e7e020ae3ec95b2de6f5c85e0c463b7cc4c2b9c0f1bb119b43218be721bf06f5fee4b3dfb9d66160eedcec69e55cbbcf2ca49c50087ada866dee44910bd9af8af22df526ccae37077cfdffa95b00494b27503d2ee4c534ec9bce495de8bcb13f59be97e8f112799c896ae25ec335dd15acd3f1286e9608d41746276bce1e1194680cc6cb08989e4fdbd422eb366e7ffee4e0f322387889b196dae8e9e272af02face09368027e70d938f3a2fd5f9f571c15a6b47d078bbc0b7fc2db16fb36c92ffe2ee1df04a9e32e08e60de2d6fcfbf0a75ab9c39f37b79ee7a36154c8611b1581105c7f64eab7102f7044acbe3d9c93554881ecd4cac6c262f342bfb06ceceb502e79091dc4dc258e92a97ebf7044c935cd9da7d10da0448125f9f4778176c7e100cfff02e68e66bc0c637bc77410a0c95ec0304542f68fb2e99a901f252dd57de509ceea43703e6c9ac75ca9dc4fe53f9a43e1e9a5c76d70f11f0497a1d8cec720d2b1f1fe0a87aee07227afbce01792d84826ab1e89bda6bac1086c61abf551400a50ea8bcdd77bd1585fe25bbabcab6cdcec71b9208f342ecfdc5962a0105dd37c6c93195e7a4d094e48012f857a1f46fa3b084df4fbc84eba936e576d3ee46ad8bcbd4d88afe858a027a2e2e88e83f1eb461d5f75bd85229116fe0de838ccc041e601bdee5b0d27a363fb71e6b32d38ae8d91a5b3b5bbdc680bda0212e6a96e1ae56195a1e4437607a0a30b4a846658688113992e6477937df492cbc2725561002085873ac064309f52da09b1d731d126e16c3f4a541003cdd6189aa8a8b02e9a9d7e4fe7225fd66df0561137dfdbb9b5f1af9d0ead13a517f70ab13aca83e23302d81fcf6fe8f6f7cf5a0853882d2db4b51038b00b05a9ad1d937d28717dcea9fc20fe1d78b642360c9364b437cfa40fd29cc9664695ccbe21de102ffac43f2b823f2cba2a897ced75b22768d3d27068befd1e641e36d25677c9f717a8c5867e63861cfc08660963525650558aa8338540f3dc09c5c0b1bd419da262949d5a6e4bd6936459ae85a7194052633ae4951bcb2fe8ccf90b47376222ee1a48ea026303b9e0eadbd008dcd81588363eadcf67d6f7cabedec1a24aa9266dc0fbfa47709a7d8536ec137026bcfbd9af441606f7184cda8f37940edc3a9f530602940af1f1f60b9b436f8ae6bb3bfe9e9934b1271322e43f4d4d2b7d01da105174e583052f00f7f06ad2d5bb85832c4e0be495dd3830e482d696a8f6a66d5bbeb39a7aa6fde9feeb70274538712f948963e0a60f6cf9c642e51f5f75f1d29e092812c7eb87414319a89b085af19510fab237d97d10f0aece0fda403eb73ac10e740b34b0182c61b140f9aa8ffeb9e7460a3c230f8e28495543f372c29d4f3273a34e3c26ac7c79c02f62ff045b2b531a22c2df9705a722020d48bb72754677fdb2ed61db598a129f033166e201fc2c9a81ad177fe28616eb8698b61788f0fa6a369a6e5e5769dc5d515132d86549cab8cdd4be489a3e6cf35431b6e3355bad92e83df2bb8dcc28d96c4bedcd7b05f067be6c27e515f7f24808565f419d8e8fb28df6400851f3592bf32201988f34f5bf8e78734c75488a5a10d49a27892188da7734d317d8b07e10d10108f1113b3fdd7b2c3632624968dae745df194f86bea1ad7476148f3c223419d60932911d18e80732d9a09d271a5e3927b5c529e76b6f64a5510dfbd23d6be0303699c57f3ce69650a8fb1d6d6ee498a3ebae9b6d48f542fbd39ee186eada0c23ec5d53663988237b588b3f2a0aa607ce7ef7aa59f8771b39abc71b119fb831859a05a1b9b31ebb0a213cae01c08b2def4d537c7f7c3a1ac9f361cf1fe387618d1147cd164af9243e9c8c7681a55ec8f0a5e2f12e82fedabeb52f76b267fe18af96bea76b99398508db6e38a54dbdef1213862a2b9e90b0946dd7f5c8c865b6fee0d55970915ebdd2749d870ea64b0266828a56e224279e9bf20115f86392b02cec9275002bb7473bac4fa3c77a01e4857aaebfe44cb9ce8173cdd80f7adb6605c2ec42b8ec3c87e2fdb99193e237765fa3c5f63f1a1a27d524ffab16e1d166ce3e9b7a853381ccece84094458efc29e298726bb704add67b970ebf3a33c02bd245d9c226a31bbc325f567f8481d090ba65e2cfe5ef14d1f7ea3d6336149ac727510c8084755aea86f84b3ce152048fac40f9c7eafeeb2b747222bcc59d5f21b8c7993f8faf3d5bb275a743cd64f854f7353fd9dbcaa39884206966491db363b4f752a3fa17687e33979dc8634cbdb0645390a4d8a855991b0ed4f544acee793e56eb4ec37f8d0c8612bcf7631acd41f40d0f9628c06dce3c58f77b7993c70fab39bf5871f7da99b314d79d33a4f11bae25712ca0b8a0a351521e0843267426393abbecb12268d7a48fa8f3480bd6d64000a6a223462e50b26d8d8a210a1ac7034b5fd1360617eb908f15ba5f1ddfa5f77290951d09b35c18d92b04d8a445d42d209330b3115abb67b208f4b4b0977b39ed715d86b7b56d837cbfe23e0d15af3d95a1a2865d5c79a95bc5768438ec2c09d755726a8421317ac31cb713ef05863bf260113af3b94692652f82447a763f80e699f1ba3ca6830efe36f4b4aedf3c243a928019ea70aed6706411592dd67364b4291804f97b79cfbb2e5f9c849f66e348e78cdedb6a7f33b3fda86d9149ec662e5c65ff021b12bdbf03c09ff76ce264d187ca5485351f4c882601c6c906f8085e229c54e10f78b038e7b5408e374b5f5487919cf925d5147372ef9ce445537f275276e0064bd3814d7cbcfff9a4a6c531054cce0d46d88422149ebf76ca4c36a19fcca3b618c3b78be6593ab03a0d3cdd0634b9ca4ad35615577926b715bfc9122a2219b3f83649242c08ef22fcd3360ec823a61d19ccb3065309ffd89b84e360e9ed7086eb84847b2c5cbb00e47a508d3cb7f9d767cf8c6b3e6aa4827e72a4782051ddf0b6540a3f36419cfa24cb9434346e5a987995a99de94f86ec9be9d3455411488dcdac8f16f35b5c62e4d8201ceee93a2e48cc2f9850190876612a65192af1c538e9d0bdaa5208ca2161069301e59b5044a1a9f20acf4c0bffe6ffc149598b052d4ab7452d963551c3402cea6f1684b8b3876a0bb9f4743a7daf3c1abec79a4cc5fc70d02125720b2c979628c03a856635f4d32e0bf9ccc12f8169c9f7d668743bf14f197fc2066e2d416bf0389cf31a1f3cea3a94abbe2023a360a3065620a9c6e9befd42fec2d6bc3b801e4205a17571254dcb39f01f4ba9e70361de41e53e5c81788b7ec3cc776efe5a72e8a197e77ffe5a1c1dbdc4879ebc7d9b497f91f1616fd7c4d6525204db31b627c4188000d94f84c533e3e3a3030dec4fb281c78842e903da1e912466ac14615083b2d9033115b98094e1ce071f765e9b19346bbafbd3b79e04655305b0555025e65a9fd907ac1b739f6fe77ae884fe6390f9e578f45377af5e1ee638c24222f579780331888a7cf68baee662be55ca4c47d69aef569b547c72cee601c47e7e7bec03dc91ae335597d749ee6c503b1e170c75927880909b412bf43a821cd731bce833c565c90a0371ef252eba42e1b697b14a36c2e5d59303f70f62685cf575fbbd9f9be387c2170f24531fece15249fc20e8a83802f0990ddb3d29e6af3fe9c1d2b26ff6d0c1c7c5bd05c3f6ce5ed540e124ed8e43b0d731b0e96899a3315427a2979ebb6796be91dd1ece0d47917840b4632ab8ce69bcaf1d3c3cbed470516ae63cc98dd3610f3e21a205f6f9b1835b84959d3fa86449f6564209c9094dc9c39e12fb6fa8cb5e7b9c0084219ec2036688101f9a188589fbdf6d438b8d664f5e8fc686ca0d8f957281cd0c29d603e04e75bd3182c7f4308887a473b97ad90eef118d9d170bb0ade5d79c4466c91a4eb4dacccdfad21224f2e9c2533e800b91dc4054170eafd53771f5f4719241c1e33044000b756c33ee2f7b4361e7f62f50e79d0377b2bcf01aebe505b8e4b0ccc79ea78a8a1aab84598adcbe63b6c9e1b3dd942f8ea42babe25bf85114624c9897bbeebc3d96e64719fa896f16af2f8352f523c3b224c1d0286320bee5b5dfc1157c1644202fdd5b67274181681903fb85562c5bb33b68016baf76f2e03413c99fd366fc98493c39d521a392d8d2283bece956a962fdc87f6dd41dc24bdfc55f6982d29537ac85bbc761815758f49ccd2bf2e21ec951cb18d4c89b5077bf147d1c0742b1fb740f0ea016bf6dd8ead3454bc77d045fdb5bdd59ee4f6019b0e855393d6f17ff8db3427bfcf91bfe11ff9548be7acf11ba95d7a2f635b74baf0d9541f872c756939bfcce7bda3a59808e6dc3cea43cf4db414514b6a460b206ccb708ed6b467aa90e68b154800749bb7c5d527e52781b11d51d73f135b73f3f6f6150ca5ce2c7c87893" }, { "category": "unknown_payload", "entrypoint": "bundle", "expected_failure": "unknown_payload_schema", "id": "bundle-unknown-payload-schema", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a27766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf01181a0ddd90dbbc6a039e6ef0feb4f57302f8e7caf04887eccded9a667962ebec8aa0444a155dce64d6a30c343711ea26a75b037b458fd01830174789fdb0271ab58b6b767057020b938833723ce23813eed955e4913494cf20d2a5d11f96a7960a165e4b10644139b86b8652aae8b00fdc63822dd79c1c79f59307b0fdb6579c5ab9b74044fccc8ef1421bd97b2c85fad83f61662a9dfa9d13773c36288b0a45a894f240fdf8cd4a2a2cb84aba625d6d8742c8687aa4688f0abf90b5cb7e7ed65e4fee19fa9d8f9365dfe1feba13a1911845fff5f6fdccec10903948b144bc320e27d0c7ce1406454bf1ea5565c360e484ec0d5f8f04e15222ed922c289fc332ea433f7aee13b99f6c58b770c13d6a0892fbb3413a20adb553fb1aacf8dab263e4220fc9e3f90d01515cdb7a72d26017302b3f24346ad2627cd5ea12d2cf3b0ba3c78f5de193a801bc581a89e3a0c25eea4aae4494d0bfa4c8f4a33607be24b22c3cc2e2ab4f0a635c7370dd8e33d4b87ce013e3748c57f9cab051cfd4a0d202230a153e7393242d096881691f9e1230cd004714abc05cc5c114bdacd39977848c2a082b06f9823ef859436e9322afdc79cf60bb0ddbaec62f65adbb4171aebd1f6d60d1302d64a6abb6a84f54daa177e18461537a58d7dc5d10b99295c8d6b6e8d9f808618c2369cab77c6bee039b19a9f6dc295e5d6f463a47f380f167c20d13c4945c685796446dad695c5ba5c6f54375891ab4feb5aa8fc9ad75104ffa95042ac159cba0db5bb0d719299267d2954945b8914f47a489409292bfb4b4e338e8575c88824feec6f470264895e1e1973a226b1ccb1c35714476096740699158c91360377161bd9afb3d3c98b00b6930103d1c186678aa9b05c2e3009b2a4ba897cebe9f6de284c26a29b24a071a216fbe990f7fc3609fdd90b36f233fafa9eec905d4da4f6450921b3392e1d73da22d9f40ddbe7440e938aaf3b4cb499d787c14843d3a0170456569380feb7e3da91f0eb889601bbddef0a56d168a607ca832dd188fc07b49008ab33f9f284264b69378d886d56ab2c101a18222866769f4b434a1d233bb7bc9a3cc77d820e93012e04e72987808a2c8ab1467ce4c46b4216ee1ae3e7081943a0d7c30aa482a63f533c8c93face8cafc9fb711d9a5b3ae38907f5a354d92a64ec98fa79c1af1b5b6ff561c7817b595b6843812d922cc49ecf5f49eb16b41cc385a05ce04acfab668f62f43f213c0007b676989fe514e94c794d6545e4d99dc0d475526d925a602516ae31f3479f87f6e8d5b6b4b224df20a05b23fdd30ca8595fc04049b686e284c0203c10ac461ae22c0edf2f5ddd271be066cf40ca2d0fb76795061f15210712606660b77fefe92eaf51b283eb2a32c6d3c6fb12fb15f2d97277f7da293cec7b1613877868fcde8cb77d78118ce7179cc06bcad03f8fd42870f46106194752d9c3fb55e51693e924c6bb413d20cca027b718dae6e328d3d10528ae0612b0a420d7fedbf29d30c604b6fa9ad8c398aca3b47134c939ed4e74905d082aff15b6efb6cf1e9b267838852e6bfe7dd8d22cdaa1160d578364f4b29cb8f3beea711110abe778efdefd431f639d9e35f8ed749839f5b974ecd9fb9fe5075d3cfd1a2e71332e54ffe4acba9bb2e5b42cb63b26c5b93a5f26ea8dd2befea36340ba7ad5219ff46b7d80d962c6a28668f96b320940bfab258e85ec68bbff6a739567459bc96f401365883fd1241b765248516df6db9ffe06529fa70cb58427ed71ecba2f206a1a3c7ae25770d8cfdfca6613aec3134f1c2cd078599872acdb7e6a4ea8417df1755d6981f5605c03613cdc3d05ea661a29890a88c4d6db0c6917718436954dd445bc3fc46560b3af522cbc921b002dcf77a2e86ed89704276f4040634dbdaf253648eb9ebf41cb5a116b598d133e6b99f59bf4f03b20bae6156d981f3c93985bd8e8d7d4aa1a468afed655140d70adc14a43c68acb5ea465d0e7b73b15b23b720f6505f962ccb395f064d1e1bc29559b0c153b506f8174592432019e898e95156642f6f79698be1fb0b57763b8f37793d8a9f69fac9e67dcba58956a3daa7a1f3afc92738c2264f76c9b3119ebaa18b938afedf1d100c6ecdcd42f06e4e3aa3ddc43c4eab6d49b44edda939eeb53830a928ac103c8a47c76c3a31efe0fc936b91d64e306b8dc0f6b5f64766fa51d3990befcf4c0acac24cff164d31d05d904a983bc3cca6edb8413f10102e3d3f4264be28a5bdbf2216e6b4c1433c5ece21ed2b96015bcdce224be33247ef71ae3c7413c14a3949f7f8309ddceb8671e6432ba503e77a4e511a1ab1660b8ed4c2ec3ea4d88450f15848234d93f744545039f07b2fa063e831aad7bd69d2ad01377119e9d94e92a4e5ef78772cf23665b791bae3a588ced9f5c7800050cf4d3a9d8a9d675006a2c051ebfcea6fc73145fdfa41a5b98c6ef07c570b7d41faf75be2d984e2bb08dff3d18734ecae31753103f919a765474ce2d0d0bb886df848de5008137627aaa14fff48d0ea18c2876c1e2dfb52de7d21b443d4c255a29a9d782d863109ed9e4cf8d1044d53523400316314127c9a9f61463e0392ef0216bc02d88e999724933f861ac7f9bbd37ca502f4214b16b5f371dbe4562a3e3b1b306f387da8a475a9d36550d558891d286e72333fca4490af76d18313de36982408ca0e4cda42f98ff98fa5b1ed96e3a9e64ac87016a07783d10cf4df47a7cdf4c128ccb127accd023648fe34405a4dfa1825fb15c278fb5ceb71d6858701b66f76d4a597825ce1432e26ad097fcae93509fea0161d6cd6c63324611442c8bf0e36032ed2b6055788f00391c84e1d62f664c0bef7ddc0190414f97ddc926bc9c7cdbd781fe87c73dba255e6658c5cd458c436499565d612484f560bf66968a71d41de6dbd388b2d9f7d6cad06c333bee6664d443fa43e5c8578bf322d2fa43c06d1adbe02fae20d6353cf929d799dee6a74a4e3c1b3cc512f43baa030d2d1c1647cf4f00fd45f84ef147cb4881ee56f41262445850a0b703e7111547536cbb4d4a633cc85ec5affa1df82a240bfa78217e8629828fc1ddfc172d911a056cd241a383a54fae9686e58ae0ad4dfd5ba6a9f97f5a28a59b2f0b8ca4d9edffc5efb47a2dfc1a8ddb67087a81951711d2fa353b5b2eb56e6ca5c8d32acc2c887d400024fcc7cff80c063730391340fd0691910612ceb4445e2902880270f91da20452e094dfaf2f31cfdb37a4a7da123d70ebb5bfb3e06b7ca217bea5bd0691eff11a93f784c9a2f097673ed8a2ddda383e035db853fbb1eb24134e0ec65f93953e53cccc7d0a643c492651c099a966c1bc30b86effb4065b30d5fd53ee890e365f6881fce41a3245656e90cb672edfccbd4f7d917746a6283bb53a4870ba61a34d3b96c3b9c54c8ee5901d7d5d488e33f09e0b59819c7b6f43920f578803b360e604414d5b5474fdb356754f122efa268ce6df36f24c1b6c66dfa5662e1a958c98094368aea844416d6acdc48255c1d17d91c17e9b46603a45d5f58e286802492f85c55d73fb88fa0898d91967b3a16536c1968b5e048b34c39d9959f6bc17582e9940e12b7b0923b211cff7aa1c8ba4548dbc64c2489d7ee4fa25f71c84f6279b6377a68d5c6d60f2d2d3b48fef6aab786f17b7ed57235d3f08fca0b01af070830a2d8aa9f9b5f1a3d494e8cb16f655174ebe0976d85121ce1fe36acdee5541c8738ddec9ecbd3caba2c33f6c81008a54b6647ecc7e0cd9e2999afc79eb796a797ae7a2fd8817f52876e6cccce996f0e1c08c93eaaafc5692b0282b1895eac77618cfa396680a13a1c803c16dd593a74b12c934107b62268358f072cccd333e0f95a7d69380008ef8c009798747855da739533053a3f7b481be6a8b08f6bceaa679b769926f4275105c4e750a26c95b8f2873c4a5f0320535c10173c4b5aeabd1d971ab4969a763363ecd765d324b32d141e4bc57b27057e7b08cd0bd2c054b8722a6e8a3db1a05a34e665004b167fb654a24627976d4b2be2bcb76fee9e54d815b56f95d9d6554be6de6af13ac77c356e0a009826a78aa731ede2d7fb54990cd826a4715c45dc26f60630f4f6930b0a5ad82195e0ffc338833f2210ad24fe53535c7cae0cad0664c3a9c4ea4dd2b94e0df0ae5392e02f9a1be20857c19ca57e3808b3443a44b8378047beae92116a7556f5f49ccbe824000985ee820b8b009d64835a2d69418f2a0f47a6cecb4b1d04591fd2429f743b2842b211b6d5" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a27766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf01181a0ddd90dbbc6a039e6ef0feb4f57302f8e7caf04887eccded9a667962ebec8aa0444a155dce64d6a30c343711ea26a75b037b458fd01830174789fdb0271ab58b6b767057020b938833723ce23813eed955e4913494cf20d2a5d11f96a7960a165e4b10644139b86b8652aae8b00fdc63822dd79c1c79f59307b0fdb6579c5ab9b74044fccc8ef1421bd97b2c85fad83f61662a9dfa9d13773c36288b0a45a894f240fdf8cd4a2a2cb84aba625d6d8742c8687aa4688f0abf90b5cb7e7ed65e4fee19fa9d8f9365dfe1feba13a1911845fff5f6fdccec10903948b144bc320e27d0c7ce1406454bf1ea5565c360e484ec0d5f8f04e15222ed922c289fc332ea433f7aee13b99f6c58b770c13d6a0892fbb3413a20adb553fb1aacf8dab263e4220fc9e3f90d01515cdb7a72d26017302b3f24346ad2627cd5ea12d2cf3b0ba3c78f5de193a801bc581a89e3a0c25eea4aae4494d0bfa4c8f4a33607be24b22c3cc2e2ab4f0a635c7370dd8e33d4b87ce013e3748c57f9cab051cfd4a0d202230a153e7393242d096881691f9e1230cd004714abc05cc5c114bdacd39977848c2a082b06f9823ef859436e9322afdc79cf60bb0ddbaec62f65adbb4171aebd1f6d60d1302d64a6abb6a84f54daa177e18461537a58d7dc5d10b99295c8d6b6e8d9f808618c2369cab77c6bee039b19a9f6dc295e5d6f463a47f380f167c20d13c4945c685796446dad695c5ba5c6f54375891ab4feb5aa8fc9ad75104ffa95042ac159cba0db5bb0d719299267d2954945b8914f47a489409292bfb4b4e338e8575c88824feec6f470264895e1e1973a226b1ccb1c35714476096740699158c91360377161bd9afb3d3c98b00b6930103d1c186678aa9b05c2e3009b2a4ba897cebe9f6de284c26a29b24a071a216fbe990f7fc3609fdd90b36f233fafa9eec905d4da4f6450921b3392e1d73da22d9f40ddbe7440e938aaf3b4cb499d787c14843d3a0170456569380feb7e3da91f0eb889601bbddef0a56d168a607ca832dd188fc07b49008ab33f9f284264b69378d886d56ab2c101a18222866769f4b434a1d233bb7bc9a3cc77d820e93012e04e72987808a2c8ab1467ce4c46b4216ee1ae3e7081943a0d7c30aa482a63f533c8c93face8cafc9fb711d9a5b3ae38907f5a354d92a64ec98fa79c1af1b5b6ff561c7817b595b6843812d922cc49ecf5f49eb16b41cc385a05ce04acfab668f62f43f213c0007b676989fe514e94c794d6545e4d99dc0d475526d925a602516ae31f3479f87f6e8d5b6b4b224df20a05b23fdd30ca8595fc04049b686e284c0203c10ac461ae22c0edf2f5ddd271be066cf40ca2d0fb76795061f15210712606660b77fefe92eaf51b283eb2a32c6d3c6fb12fb15f2d97277f7da293cec7b1613877868fcde8cb77d78118ce7179cc06bcad03f8fd42870f46106194752d9c3fb55e51693e924c6bb413d20cca027b718dae6e328d3d10528ae0612b0a420d7fedbf29d30c604b6fa9ad8c398aca3b47134c939ed4e74905d082aff15b6efb6cf1e9b267838852e6bfe7dd8d22cdaa1160d578364f4b29cb8f3beea711110abe778efdefd431f639d9e35f8ed749839f5b974ecd9fb9fe5075d3cfd1a2e71332e54ffe4acba9bb2e5b42cb63b26c5b93a5f26ea8dd2befea36340ba7ad5219ff46b7d80d962c6a28668f96b320940bfab258e85ec68bbff6a739567459bc96f401365883fd1241b765248516df6db9ffe06529fa70cb58427ed71ecba2f206a1a3c7ae25770d8cfdfca6613aec3134f1c2cd078599872acdb7e6a4ea8417df1755d6981f5605c03613cdc3d05ea661a29890a88c4d6db0c6917718436954dd445bc3fc46560b3af522cbc921b002dcf77a2e86ed89704276f4040634dbdaf253648eb9ebf41cb5a116b598d133e6b99f59bf4f03b20bae6156d981f3c93985bd8e8d7d4aa1a468afed655140d70adc14a43c68acb5ea465d0e7b73b15b23b720f6505f962ccb395f064d1e1bc29559b0c153b506f8174592432019e898e95156642f6f79698be1fb0b57763b8f37793d8a9f69fac9e67dcba58956a3daa7a1f3afc92738c2264f76c9b3119ebaa18b938afedf1d100c6ecdcd42f06e4e3aa3ddc43c4eab6d49b44edda939eeb53830a928ac103c8a47c76c3a31efe0fc936b91d64e306b8dc0f6b5f64766fa51d3990befcf4c0acac24cff164d31d05d904a983bc3cca6edb8413f10102e3d3f4264be28a5bdbf2216e6b4c1433c5ece21ed2b96015bcdce224be33247ef71ae3c7413c14a3949f7f8309ddceb8671e6432ba503e77a4e511a1ab1660b8ed4c2ec3ea4d88450f15848234d93f744545039f07b2fa063e831aad7bd69d2ad01377119e9d94e92a4e5ef78772cf23665b791bae3a588ced9f5c7800050cf4d3a9d8a9d675006a2c051ebfcea6fc73145fdfa41a5b98c6ef07c570b7d41faf75be2d984e2bb08dff3d18734ecae31753103f919a765474ce2d0d0bb886df848de5008137627aaa14fff48d0ea18c2876c1e2dfb52de7d21b443d4c255a29a9d782d863109ed9e4cf8d1044d53523400316314127c9a9f61463e0392ef0216bc02d88e999724933f861ac7f9bbd37ca502f4214b16b5f371dbe4562a3e3b1b306f387da8a475a9d36550d558891d286e72333fca4490af76d18313de36982408ca0e4cda42f98ff98fa5b1ed96e3a9e64ac87016a07783d10cf4df47a7cdf4c128ccb127accf162d55f23e255443ac1e36f408d865e65aea6c879de621ef2b62c9a7d89a448a473339b00f6ecb8f3b07aeb7120f70d78a2b3c001843c0b00024043dc7ab035699fc199fc1410863f173dab7e4cadb160b18f81bd29c3dd4c3c5fa605eb13c7edbb97eca739a5cce5ec5077f9823ce0a08425610af327ba10a5ac33db336c42f9e7c77b607c43982cd7b494c059531538464ee2a353ca53c14dfa3eb14f7e23e484ecb9aa7539cf95646a0e6c1b6d600375ba9a130c08e906a2aefbd17cc3e88ee1c73ba9017f6785a292b49846c542169285540683484acccc73fcd8de354e7a8cc95b94fb0ab8371e66cc83ff80fc3c03ec109c15acc390c33205ff7fe7b7154bc02c4c0c0b1619ef1fbacdc44b6f8ffd20cc79ff15ee06c8ec9d7a8c7b071b69e141769056fae53aeebb045e0dd479062a2cc8785d5011954cd7bf5bceb7e770bab0500df6805417934fb4645f09e26d5310291e90b582a0177d0f3e420cfb77f4a78bb72cf16ab59fb2c59e6c47760a743c80812fe0cbf346247953a027c7eea834882f2d4b971cf983df406aa2038e1f173f22358ea29c7cddda14aa19c6b4c1e9e84701a8f139e8ef74178a50645de20ea90e570f2991ea84fad724b52e14bae33b4bcc6d4ecf23b627c6299bd5295b106e0022c93b46c20c50fdbe84e1aca85da86ac3d08e1ae8318c0bcc8123df170ba0a3901615845cdad564cdb27695aa434e2a25be570f7671eebb7d151c85267e1ac4298988c2789ea961b47d8fac7053d447e15d91a01e9b86a15b55c5a2abdd7d77dd6ec41579827a0eca2898b8796773600426d1c1abbee18a4482bc594d373d91680e9921812bbbf902c2216fa74aed4b14a59c9c65a25efd9e019bf5b798f572320f63a7a73feeac0192d373d49cfc0a7f19ee93b73d5696c88e389dd101cff09067fa0d9ab84805e1d37756591b5675f607be4e18b3c9d0a0ae5fe24a2d0b04211870bf6f19ac3e9e0bb3b0fc4cc1508a051375ff4d1e4cd8c76c8a19185fa8ebfe7b97e2ac5fa2003d03728d8d39b20160478de3aafb2be67255295b59b42ad3b799488287586bc3016992d19c7523673a43f955006d02c66639203248bcb72b9b9577d72132c1ef98c1a9199454e589c6b8d73083a24221308e0bfab923bc0a4289977983def26560ff8cc4da664af89fd883d560e1b384459100532450ffcb01da431a992924c1962fbeb57d721b3280e4f53dd6c22056c2459c35dc98d4ca0022d758a3ab6a93f6bb7430e0f1160b719f90010a67c710ade94e024a4b342dc17af64d98091253495bb01de53a1460c5d2637bf109c988f16e1deeddf29d847c023a0675c458535a4596fb08bcc4c1f46c3239acbb49d38c97c320bab33b65545127aa40dea4b34cdae87e74cd3b34b0b82ac0bdda9528408e3035180c3f56f3243a1057a0faa38987497bf890f66454da3ade191a3644e109aadcc4fd642c0769b0560255192675c01d4f2e384acda6907bb802817bbd23e5758fec5427f62cee642cbed1cdb248c7ce8e15bdf940cf84550f5ef8addf88fefb922bcd6c8fe9b7722c4053ea3e82e0e9f34d1a1eabdf1eb6c3998f47193ceb5e78fb91e85a1bdcf6dd1349637507f8e354a6cb3bfb6ee5ca9973643668501eb1c27237f6274746288fc02b64f43fa7d77eec54defd1bfef27088d62d001d0453711b3bcd724fbed3ff585c4509ec7cf08662dbddf07b22afc00559ca332aa755a526eca40832aa3b240ff" }, { "category": "unknown_field", "entrypoint": "bundle", "expected_failure": "unknown_event_field", "id": "bundle-unknown-event-field", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a28766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf56876b68b1bf2bc796234e431b8dd276d4b7afaa3281b05fa93c73525e8b9abe1bcf49b0e3524f5040d51a2c1c6524bb6e3e5411b5b75f346f8bfc61e57ee1aa3c3f91f8500b3939157b7d332443523a77a86866518397f5a6266f446034a762cbe82114ba2c20c70f85d17d930c05c3505868e64b63a0276797cf3674691e54d1646c423f51bc8770b0fcc2d548d98b528ac5d915cc5378b8b3d84e347cad3cef36b7c7bddbfce2d4621730ce7d218bb654ce570e164d142ab1a6151cc3aa42221eb58fd6dada1c90ace4d38d26a9baa6101440e13b007ff6076cb0d7586063e77c2693e5e3dbe7da0f42841dfe4744e2891f7cb2c1b30a3bfbcaa76c7408130ff7daa7bdc8d65876672255fb614ac6846835eae7b0a566a20a9a34c45254cccdf18e0d7270cb694274401f3ec5444740659e7db10a862dd20b973c8e49f0ea88e237dffd6536390361cdc59faafdc123e659e6905085e0711fb0a3a31bd74cf733502df42470f6cc9a9af243b32c100338eb01d68a8cb653d6beb1984142e2893a5d77e782492f5644ecf546579bb7d022307e938d05fca679084a0488501e76e444df3f9116ad699c0fdf791e3b6162d5a9d621b1cc95672bee7d43e71ee98c0fbb7476ff2f0255a94255725f86a4192c1174de059eabe48e5ef361e0e74456e4cb2a0d67847a445a697a109161b8ed5b8b1dbc02da8b7856d2015de7ab0d358e14c24d9816756d369a86992d94529b7e24747ddc7de89f8dc853cb1454c34897fb2fcce3ffc8c169bdd626b47976d62771e02a2643ae1100a876118d5ded86ae976fe93bdfc0bc8217917ca9217cef2a2ddebbfd97eab53ab440a35e436442eb70a82a242b799000a5741769349aa4bd3c061fafa62a9ff23d229a02882d53cc15131b2f07c583c1e9883d257c49f3ad58db230ea63558552df0ae86c265f6f9778009c0fa4581074dcbbdecd38b5e4a42cf86151e187ea3be7a7be78c435905054ca5ba72f0a49ea0ccd991ba9f5cda5ee6e74b38e5ed6b6a1e7aeec589d7a3c4c0be08c659731d8387709254e1d214bb362330e0398f0b7fa931c3b08dd5768d5df213cd63cc45d7b3386063a53fec5a120a1258fa8133777398e381e0456ba84a4d8b30f2e6df77009b8dc91d08759a965c898b2176be6cd8997b3dc00de1cdd517ed8efbd859488cd27426059f7362eaf590201c82c88f4845f35dbf670379d62eff10b95537ec577cbe43fd7c8efa49a8bd5c0ef84c32b43fe177c4369b9f537eb3175cc40f23e81824f9d02cb49f0c537deb37dba02d80b81b6d9110e7689517d3a5111eeaf85eb37b37ffde8d9d56af167fcc11f8f2558d79c28fd30e131280b27ed8a513051c68d4b64ee911d94d3bd68e4556dd4bce6e3738a70df653a9d36faeea1ffc62d146957bc3c1cfa62bc51fc763f92669615e862a1d23e53cb686968cb3eeeff0fab3772420661e6b01c8358271f332e95ee7b4be5464847f033264433c650ab04e5c9a4b4c05316a4dfc75d98d37a526d96ebe2ce1628ee21269d11a55611b873e98e95cf4a249cee39d829051db7d0c38c792f12b55746449b83e2eabdd2682f4f3aac9bdf3ef9096a2bea76557362d8cd172adebf8858175fcc97b00cc1ffcca59e30e6fc84c627a71d6fefe09de5f95778624cfcab1195191a1ce741c56ca6c85cc43ff49716124801926dc1a37424e27329f4eb231371f33e7c78eaef4535c5d24d4603a6018ffc1145005a2dd54e2c3e626218645b02a119cc4a14f294893feeec88d13c7ceb99ab122ba879e9955cab169caed6c48db668a4d51eaa6fcb54b0d78b5b1d6044cf90d16efdc4834f711ed87698689ee0bed6406373b0e3fc9cf4b76d9decf013359e87254028c040b19426db6e29b612e03a1dbb41a508c4c913667a5442971c62d6db85bb5be7fdae006184f84a928ff00ab4cd3ff28b10bc1e1544c602d74909194a22c70f059891310f1abe29473d34db10ed614295ca58c8c34c13c0501d6c7f15bcf4aef8fc41eb22010ab38e1c29a6d2c91bdb6cddf82a2683475d42d0ff0a2f03f00039457508d95daf8f187f02a736c0610612e764a8069cf8dcaa6c0eee11628107da85ae0750e82a328ef2e73fdbe2fa2095cadc11a8cb9d79bb616ce3b994bc01a7719a153e2bbcd81f8cfc4fd9fbf216c46d178d07dff25ac3e6e480e87d1ca9cc62e3f9662966b236d429737029859d488ca0e963da91f61b48ad150b276fef68e513fd55425b640c53a38bc61abd7ea0279f85ec5f666b3984151a6f1ca63f1d2f911dd0a75b1e577a42a52141b7bb4ae4907d842874cff189606102f17a16347f9363b60f910bb5c746496505e6eef8df5926af5ab1610f82071b698339349bc6bf1b350316b1ed101e5b6e3fcf82be1a7aaa0bf841e001757fb9954b26aa7fa12a5faa66d14adfe8003335290e9b3534a944e1edc650c62da3b9939dac09301ca2645c563afb85bf0b9ecb749c0648fabd95f6801a59c13f3c13b292c36dc2bf0b2268342e03f31db4681395ab79d01781a8bdb13ff07776473df3917e318a26e909430364c0f9dc02e3534dfd881f3206818a160cfa37cc729f3a2222c4985ac4aa0d08b1caea0864b2013cff5169ec9ab9fef74cd0b947c1f527b429982126283b43a5f45d10c2b6096bfbd91223fd9f94b83135efbf3e1690143c80e0f0c0500c423899f38da496f0ff614a26502a45c7c192ea777d0a572708761fec04c0efed5748abc1aa316c7890053990ba3824e8b2ac990084cb279f9f60420682892daad2a2c739709b999d8d6f6bb5bb9d4257b49ca271d775be0e1d05cab26a21b4e3fa67b63f4832a5b3d63b10793f3b682b0427ab375ad683e8c42d0f5dae0ee8122efc661858a16465644ab61d723d8a67df177dbace11084a47eeb0a09dc2d717d5a91d28cca3a7c40730ceb5f6415941751e43429b823924a03f1999fa143d19d97872c1116a77d88b7900602d8e66186cb328bc656954dee807d5b27b005a39331e3504d494a618f45bb366aa57c4e2ee5555194af7862bb249c76ae13af9632b14915b885b4e49097c276ae29f3e49829dbc80919b9c886b76ba7d03649ebdf8aa58509657fef1d27b95b7e620e8edab53075d23df12092c15931d5656ee667a686fde374db59184fac4169946cdabc0f2d50f09115e3b8db3972fedd4c878c0d8d98f90251309822d74980459fff901cf8e43a6fc012a616123797aa10303670f77bb95c2efd7018c7c533f4386614b03731cdec9b5492c81fb920403f95400196cd6b3da4bef31a0ab89dcf61badd6008e0b58cb4b7b0833ff9e94ea4f805cbca3e05514cb23d2924446a3d2ff192c65a711d8a501acd5ed7d85925ea400b115703a408fc7eaf89d9de72d6782508d793192e8c1b498ba98d4809694674d79b95ef3ca5a2ab7bd36b0934aa37d51612a19b208dee6e1d4a2651a3132dfd5aa745e37633c0837c5093ae3aa87641c3ac3fe8d9a2bed025b8ffdabe9e8e292d26d616fb25a35ca50b92e3db06be86f5b5a7c7fbbcee7218671e67a41d080db31f0d4da14349f4229ba3ed2477ee67807fa3686d3cba66fefcab574dc47b5f7e1dae98c67e821825ab2d7a5de18f582366fa941e3c2a8ff851fd732e6139333cf2e9c4d4452e5a8cafac770f8f877fdf3251d7fb575b45c9482576913596ba7ab0b0c8cd95f475698b81cd6b2bab24614f69eff7b47adfeef43711f1284495b51580f0d586fb65ae1e1fb0cce776799999b383334486fb05960c8a6f1b33ad95256c3d681514f51a061f452b73ffaa8e738ea871ed6829e4ee25b3d0cd1f47e3d5c87a70805c02ae9747d81c709ec7d0b5e4e5f07f0953488de7bd346c07e3989097794ace55e857082ada1836920a1f687d0fdb01959bfac7059ddfd0a8f7460d005829f619a5aeced3a23d159ed4bf57a93ef2848aa6f0c62c03fc36cd5693fe7f1ffa5d031655e832f9c2f9e5ddbfbce20f77838ff8deac5cb0adb94203e9cf736c2b05fbc342631d7594d463a25bbefcfe61900edd02883bee14c9c6672be4da3f63bcc1663b3f65e9428eaa6a1ababd2e949277bead140583bbc3386bf764d3d3c66fa243293c762f38794ba88029eb7a749d06aaf8c78f4d658b911c2846dc64d075e8984dd01d5ac832332030264d8ec42190796a0d5b81c5b1b3f07cd16aafd04faa0fdb57833eeaee0be784ea77893524e3e4e96bd79a6c4b49b574df216038f2631671aef060d3e176d28423f76a42e12926ab2cfec94bec8dd436c821cf8b65b080f030b9264e41" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a28766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf56876b68b1bf2bc796234e431b8dd276d4b7afaa3281b05fa93c73525e8b9abe1bcf49b0e3524f5040d51a2c1c6524bb6e3e5411b5b75f346f8bfc61e57ee1aa3c3f91f8500b3939157b7d332443523a77a86866518397f5a6266f446034a762cbe82114ba2c20c70f85d17d930c05c3505868e64b63a0276797cf3674691e54d1646c423f51bc8770b0fcc2d548d98b528ac5d915cc5378b8b3d84e347cad3cef36b7c7bddbfce2d4621730ce7d218bb654ce570e164d142ab1a6151cc3aa42221eb58fd6dada1c90ace4d38d26a9baa6101440e13b007ff6076cb0d7586063e77c2693e5e3dbe7da0f42841dfe4744e2891f7cb2c1b30a3bfbcaa76c7408130ff7daa7bdc8d65876672255fb614ac6846835eae7b0a566a20a9a34c45254cccdf18e0d7270cb694274401f3ec5444740659e7db10a862dd20b973c8e49f0ea88e237dffd6536390361cdc59faafdc123e659e6905085e0711fb0a3a31bd74cf733502df42470f6cc9a9af243b32c100338eb01d68a8cb653d6beb1984142e2893a5d77e782492f5644ecf546579bb7d022307e938d05fca679084a0488501e76e444df3f9116ad699c0fdf791e3b6162d5a9d621b1cc95672bee7d43e71ee98c0fbb7476ff2f0255a94255725f86a4192c1174de059eabe48e5ef361e0e74456e4cb2a0d67847a445a697a109161b8ed5b8b1dbc02da8b7856d2015de7ab0d358e14c24d9816756d369a86992d94529b7e24747ddc7de89f8dc853cb1454c34897fb2fcce3ffc8c169bdd626b47976d62771e02a2643ae1100a876118d5ded86ae976fe93bdfc0bc8217917ca9217cef2a2ddebbfd97eab53ab440a35e436442eb70a82a242b799000a5741769349aa4bd3c061fafa62a9ff23d229a02882d53cc15131b2f07c583c1e9883d257c49f3ad58db230ea63558552df0ae86c265f6f9778009c0fa4581074dcbbdecd38b5e4a42cf86151e187ea3be7a7be78c435905054ca5ba72f0a49ea0ccd991ba9f5cda5ee6e74b38e5ed6b6a1e7aeec589d7a3c4c0be08c659731d8387709254e1d214bb362330e0398f0b7fa931c3b08dd5768d5df213cd63cc45d7b3386063a53fec5a120a1258fa8133777398e381e0456ba84a4d8b30f2e6df77009b8dc91d08759a965c898b2176be6cd8997b3dc00de1cdd517ed8efbd859488cd27426059f7362eaf590201c82c88f4845f35dbf670379d62eff10b95537ec577cbe43fd7c8efa49a8bd5c0ef84c32b43fe177c4369b9f537eb3175cc40f23e81824f9d02cb49f0c537deb37dba02d80b81b6d9110e7689517d3a5111eeaf85eb37b37ffde8d9d56af167fcc11f8f2558d79c28fd30e131280b27ed8a513051c68d4b64ee911d94d3bd68e4556dd4bce6e3738a70df653a9d36faeea1ffc62d146957bc3c1cfa62bc51fc763f92669615e862a1d23e53cb686968cb3eeeff0fab3772420661e6b01c8358271f332e95ee7b4be5464847f033264433c650ab04e5c9a4b4c05316a4dfc75d98d37a526d96ebe2ce1628ee21269d11a55611b873e98e95cf4a249cee39d829051db7d0c38c792f12b55746449b83e2eabdd2682f4f3aac9bdf3ef9096a2bea76557362d8cd172adebf8858175fcc97b00cc1ffcca59e30e6fc84c627a71d6fefe09de5f95778624cfcab1195191a1ce741c56ca6c85cc43ff49716124801926dc1a37424e27329f4eb231371f33e7c78eaef4535c5d24d4603a6018ffc1145005a2dd54e2c3e626218645b02a119cc4a14f294893feeec88d13c7ceb99ab122ba879e9955cab169caed6c48db668a4d51eaa6fcb54b0d78b5b1d6044cf90d16efdc4834f711ed87698689ee0bed6406373b0e3fc9cf4b76d9decf013359e87254028c040b19426db6e29b612e03a1dbb41a508c4c913667a5442971c62d6db85bb5be7fdae006184f84a928ff00ab4cd3ff28b10bc1e1544c602d74909194a22c70f059891310f1abe29473d34db10ed614295ca58c8c34c13c0501d6c7f15bcf4aef8fc41eb22010ab38e1c29a6d2c91bdb6cddf82a2683475d42d0ff0a2f03f00039457508d95daf8f187f02a736c0610612e764a8069cf8dcaa6c0eee11628107da85ae0750e82a328ef2e73fdbe2fa2095cadc11a8cb9d79bb616ce3b994bc01a7719a153e2bbcd81f8cfc4fd9fbf216c46d178d07dff25ac3e6e480e87d1ca9cc62e3f9662966b236d429737029859d488ca0e963da91f61b48ad150b276fef68e513fd55425b640c53a38bc61abd7ea0279f85ec5f666b3984151a6f1ca63f1d2f911dd0a75b1e577a42a52141b7bb4ae4907d842874cff189606102f17a16347f9363b60f910bb5c746496505e6eef8df5926af5ab1610f82071b698339349bc6bf1b350316b1ed101e5b6e3fcf82be1a7aaa0bf841e001757fb9954b26aa7fa12a5faa66d14adfe8003335290e9b3534a944e1edc650c62da3b9939dac09301ca2645c563afb85bf0b9ecb749c0648fabd95f6801a59c13f3c13b292c36dc2bf0b2268342e03f31db4681395ab79d01781a8bdb13ff07776473df3917e318a26e909430364c0f9dc02e3534dfd881f3206818a160cfa37cc729f3a2222c4985ac4aa0d08b1caea0864b2013cff5169ec9ab9fef74cd0b947c1f527b429982126283b43a5f45d10c2b6096bfbd91223fd9f94b83135efbf3e1690143c80e0f0c0500c423899f38da496f0ff614a26502a45c7c192ea777d0a572708761fec04c0efed5748abc1aa316c7890053990ba3824e8b2ac990084cb279f9f4103b752498bfa3247a758406a483c5cbf0ba46e8cc445b108e33007514f8f9bb58b639bf1d5f3ec0756da4942e492162fd1f8b92ba83b84d74a172be7d238a43c1f9c0eee78e37eec1769f8305514d4ba46dd645d6a82bec73d3ebf951dde473eb1122f03b677d4097d3bdfc373a586b4ce65f7f4ccf0457f32f34e82d9c0501f09884ba42d697abac31151e9d4c87b8911a53c0fe7287cb2085c8038340eeb3564623b83f899226df62494c4a649514a32e79a47c5c71b45b078fe2607ada289d7ea11db79f21a6521ab789b582cfc6952fea3deee6d731c3a90518b1c788af62b4c72d46e4d38bc38b073568eb0f3bb817667a6f82dba8267ec836fc3781de5523dd7571f36cad879bed7a8d441c47eb5928cd2cd7bc14067ce68715f9beda0844f39b549fcc008d83a05942368f39ca198e4bd0fd911de3ff3b68ca2e8d0b163fad9b1f3f376ca663a14c2cfd6216c99025f938553fad3339f7c69a43aefa1bbc204525c4581986cf6b2ffbeffd4c11f585d700b6dc7d1eebaf87bba2bb8932fee02bfba952928e2a185303aa25482845772b24eb99c94f7a17875713a850d9884e21f85c0a5d4f1bc504fd63b982c3d56cd478231dd382184882151f96ad850f11281f34da9b8ec410b3b4ab61d56a3802a771cd0e52ac9b3bd4b57d1b5d3d4cf31d23b258a644f86d32c789407b8eaa3292474eccad23b9c1baafd325aaf1d4eb8883291e0dcb12f31f895db237a0e7de06bb9ca4adbfd6f8bcfc2d4969487ce905106eb11f0b5ba14f45e2339aa69f7b26b93ec46bbe6a2224a205fcfcad414dc877496f1cabeac870d20f21b9317b11f9973b2166fc821e30258def52f776206e253833e3fbc4c244485482f9b17307c89f3e86725cd7e07c7753df483f709004a0b73ca8a888c095ef2c329887da7036fb2a6f006beef6af61dee9fe0b3aec2c4caf841a8ff1c9d7e37db81a1fa2c2e9236f949980a82e408ec12f971bb65d1f36ad903f3d25700310f508594e4b7d68b2b2960399ac74f01376b5b97cf7c4d01d08fbcdb86d74854179a09917cf186282c69cadfc97e16c0f4e4287feac3b760cef9f85847f5bcf33e6595e37de10718a4b4628700fc02ab98decc71f9bdee19efa0015181824f602fcf5ddd5b52608cedab118ab3ff39f91a7f7cc1028e132c56ca2f1701ee60c1b0e42ed32ebccf7b0cbb2bcd1246a8787c2f4ad4b8c9fbd4703ecd522743312fec3503c4c7bc2cf2eba43cefbe5619709d467d76ac81a8dc1782aa98179548dd05c1a034dbe09c4877c1eb8a725d8cb30ced26760e894aa5e51ce57ffe4e1599951011b7a13025d36e9cb31ee7e629d06f3eb95d01221f6ce5b3d5ac766df5ea3c64d9a5e4ad334246836300388ce20d7343a035cc2c8b0b2fa79d718ad8800e372c5448235ecaf54b795e03c9b7464a8f6e673e4b37d5057b944c4773945a828563fb7ef239da52b90d931ef350fad393be67fb8bb77c4e4786fa8457936b313b209121074cdb19bc5d6a715f37d7ff237d63a7b64ebee18610529708c8680aa93cdac016ac7d3cf6f6875d9764e9688fcf89f39ad839863a16dce563d18c3cfc0dcac369b83a15b97cdd7050df5ca15b86c9dbb4935daf5de6e2ec5f862e38cdfb8f0112df41d16bc457d1c7c4dd486f0d0a56eed6977aabf387c627935365603950dbe4a6a1ad92a5245263f4645b19c5b8dedf4a81f404d40b07c435797d62edf1e3af05cb59c6939613ac7b8b8" }, { "category": "catalog_contract", "entrypoint": "bundle", "expected_failure": "field_bound", "id": "bundle-oversized-event-field", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a29766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfe37e43dab920446abfc985b78bb56c0ff753d69b56633f9e111a7d15aa093aed1c82c85c0d011d5deee5caac115f4e041f6462b8e597b6ee7f03d3bfcb33a9a6d884e64b4dd470ec975d3d2a5761d234f94ca2548008b31a743c289ecf4ba4c5150fb82c2f7c0d5063922a384299704af0f7d474b92301b78c4ebb99caab99d85455fc6a6a57d855da1fd01ad6a1fe2c191c4f0bffe320196cdd98821f4a9d682cfe43fdf2e8455865e53c27dd8bd7d64f4cb054604b8999d7bdc172cad84f66603b33fa68bf27e4aafccaf152b623bd43ae7357ca589aee7df1a0c176407eadd92477fdd0c02cb1c8340bc2f1c1b662270d96ffa7d876538debfd89a4a5f30a3fb6bb0b7b5e91245686f54c82e57dfd4e2c229285fef9d1fb5c93f3143268fa0e0ad8ed8195bf1ce7bfede4e1d136ec8b8aa8dabc6adcadb58e1f507aef6f02e487ce893a5c21b7992a4d3dd0304a2558775273c76256e4f6f1d657eabfdb8e5cab00b325c85b6102315fcd079b4af2cb0ec5cb90b4333211071180e1ea49c9771a3a4e7cf8a304c06a2c603327b75665aa302f2480b0d887005da998e8f3fc9cf65ef1fbc893ac9ca761d0e6aad7e1d824af368ff29c5ce31b72bd54a82498d39e28e8801370bc4883537ebebc7da76ec442b32c321c99eaea879a86a16a9f8d1b62e8c9c40331e5a0e516a0c730ac19ed0ba4aa063f230dd5e7e9273736b33a2a341eff9ab269b4e36d20edc6931852238348d2ac5244a23fa6faeb22035b839289753f278134d5ae23c3c5b01d7416cbfbd8a828ac9af90c5c38b8285614182a6f6fa599efb2820b336d57ba14bb51e4bdf8585aa922d65efa3d92f41175549eaa1c1ab58b5aef32f6cbb2af5302f7d9723ebe72b2c1924d7917b0a1dae1fbb89edd2acb569ffc08f4435d186a446d46d71c87dfa6b8d31c015463cbbcb5909c19c22448651eb04c00f9e637f9bb60fdc7fea490c65505ea357da7f8c81cb937525dc8c008121c395c7f2c275287a34fc477635f8c46c116f345c82d8737de3f177eea3c8eb3e8464446dbbe424939f96c63c5bc0a47f49abd470dab155a5333aed0082afdf0cde1ef0b003dcaee6343c4c26dfe9d70d9d83a4c773d6b5c21968620fbb41aeca1717cc9924906abae62872b0de93e505ba65e07d94012893cdaa23f91dc2d6236b4f6df4f7a7dc90b7c11f68b8bf6370692b34b978d710078195e4376e3ae44866a90b59e9085dbc9c1e848b215ffc5af46681447bc1c04e7698d6d0ae0a8f55754e2406bd00cb86853bae58f5b8a4481384c6549eae0aea15a44d8f5245e0cbca5f86c12436c3c52af8d769818c7cf0590d807b139e5debdbb6e5d01c5293da8d0c82968a9a13191befbe57d7a5ab350bc2bff9536f529f9a8dc670f4f4994772abd2e6db7985df07adcc3c9772aafa7a1bd2cc011b3740e88e573e5fbbf83de119abaae8cbf6114130cb0a2f285aa93b3fc70067d73e53ca72cd9117794fc70bd5d4e0c1e8ea8bb7e2672a419ae09dc253ddfe10cbd76d7e7daa425f341d104b1c1ef67278b2b61e4e9d5d1300113e800680548676022ee100b1d0f24ae87121e390990030cb8cfd1d12bfc7951a7d0a296c6e8a09312d68240731b398a508ebdc0e71bd2a08527a951f21e45459c86837bcaa4985da02b5ed7302c4a02ab165b4735e5343d5a989ccaa12969ea13f62268e801984fa9faa61a6178965c2b11b0e69810292ddab96c6f7cf361875ea94af5afe2d769c58a245972d75a68aa473d8f64ab09c04ad3f29c4284c4be192214993a3c0331254db6df0394fb49a677556d915538720b5f7381a5d334cc2086d8c878492db3d3b71b8cb098cb868b11127dfa503363f36d9c457cb27eb18982db0bba8736758f674f251c1959e66ca15d8c4c6465181a170b5185575af2bd26bd8a67c4409c30e92d34e9f2f2259c04f5f8a12f76dde86a3e3b53b26be2d3b8fbae16891dd44da865109cd71e250d22eef5cfcf640a3c7fc47a6e078c2e40d55a94e918d8c6367c24d8e8fea1c8fb52d5f20e24860011ce59f51e3520a3a16bebdbfbf7874ee3151ea8437766e1d84706c5349dca60879968537b89258ce47306919cd6418ec1bb7973871387f4acfca73d5666501bf395343395b63ae97ec968ea9cfd0881cc774a1174722734dc6440fdf62bf2a9291c131fc41580854b58eb89622463d725476ea07d147ee8a294da23cfee7bcf81d8d2564049bed7f8e1d89de48abeb91daa44462ec301c99786c12af61364cad134df0ac15ccfb7581202611be4561924e14294cd9aa18ef254f497e857c8ebda43e6384f48713db30a5274d3ebac54a891306fee200352f4d5c34ace496a164bea30bea8b600e74c30c77db050c0deee1fb357e91e427bf6ff745e50a554bb4ef2c51fbbc0d16cd5e62a0741d64fcec8bf1df41a5d0f30af55e47ab2c630c86f3f3e2498f9618c6fc36477ffba43b592b859b7eac3896fa1a852c0e46241a671c413dbfb896a4186655fb08c8dfaf68270b3d1c16bd16ea1e346fbe740fe46658aa87aed51433ecbac67c864a437d76913b181b2f5a833c276b7cff0165c9a32a930f1382d7ae8af63d32096d844bd126e664daebb736e558d4e1c593347185f9b4afd87082e3eb322733e6f9ef0ac8704465dfc3ee5743e4d17da9aafbab9ac7d8bfac4ed318007cf61fd0c5b0253b9cf180e7dc1f208ba18ff073467607b6f7df14e1e330dfd07b3aeadf4b7c0d65cd374865d2e803f392b60590c0faef3a68179ad47a02dbfa6057ba8d188a1cc9467b5ca8e1e0c308c98bf4a5a14ced96bd714e8ce05a37fb2c1338f2f30824e35a45d0e193e53b711b708cd684a73d0bdc15e46745b99cef67daeab06787cd90fabf72818661a79394566c371909ed72e3c6c8f27ecdbfe60854cc97b2267a1071aaccf807d6770875d175af7a7c0b3ae8d59db0baaf63b2ac9bc05f45a9194a183a6bcd213ff6fe66743e0aeeb3823b186bc02db29abb2652dc4e18631697f5b237b423f905bbb91efbe7083852ba1e13e294e310a857556e5550ff46c5c7f178f507d1458aec3adac18d22b7f037f42f2bbbe60bed144b6a84509c24e34ce9a88cc4054bcd457dfd87ce4e2f20f89108998e1fee44848a665860d23522ce4b5b0fbe572ce9736b1b97a3ec9df0641e758cfe4bbb5141701ba230db7b22a00940bf510e5d4da8b0ebbe67ee493f12a3a45765d3e83847b411a918c48a0a21b99ce585f11ed0da994512596a1ff371d3c968dc967169a3de7f8ea260eed150029c43e43a164fbc10897f7aec070d4def9ba7b18e9eecde5e2ffc841f81009e14113ce94401077ee68298793fedc71c8fbccdd2238a54c65e6f0f59427a16595a5a692e19ee96d9b329577f92ad0268a88bafd3c032f4903bc28eb14dbe42ce297593c90a10a5c4f517735668273ba814a8c01f7eead1a7ad9c2b5ea16d62cf31748dd25b589cae14bdc8a88f9cd68a554213ab523717d562ba8a8503aee4eeff80104c4a12569b35baa59081735535527561f9e9661ca19773285adf08b81240dc11b12f3cf7c1dc2bc2a7f9548a77db26bb25b3bf115c3e175ecc4e68a527d959519aa8bd7b717ace8bc602592cf561ddbfe391e84c19cefdf8d323f6f798fdb251fece9eeba4f067d6cce110805b6117470785acd8881b812ba888d9b57ab73ae436ade19c3f5b429182b7bf217295c85a8a7261eb42ba30ca965fa0f6ea1d910f514467990218757b6d03fa4d9b0f8874cff862a037b80ebe7ff29e1c418a2ae5ccc21afdb9b9c3498e8c5b972741e9a011d8e4cf528309f322d9a524c8bceb11993e4b25b9f1e8dfc947eff553190df36718ac29983c5c6d3892f00ee40830cf83395f084df66fce782ec20ebd7b6dc3244920d815f06913a6c6937fb5191431ed5315c491a253391e4491526803103a6f5946ff4a25c5c8dbe815ed2a2a445d5e5ec8472ea802ddecb9eaedaf3e14b02caeae02139b8c0a5c3544f045a81ad3c0889c5508054bb91f263b2c6b8824407a5093d802343bfdea5881d21331fab07307da416318baac0e330c78554ae409567617056475e15f1ffa16b3bff51b32a3d8dfd96bfcae09ebfb54d1beda9c4ff222a845757593f13ace571fa0b3ae05e5043dd28dbb83291c63f61b2352db7a4c51cc97ad6db7cef518ad9a0f14da6f5b740ecf7805a2b7cbdcbaaa2f2fe69e854588207261d4b300292be9dc11927dff93d46fda971098f90e10148f63734be771ed5d0131222c4c7fcb1e32699c0263ceb6e1d431fcff02f80343211946e74bbdc3de83ebf3a0441428267ccb6b77ac94eb35558f9c5774fdbc8dde49fa557b6d80466c17d695dd232240ca1046f337d77485d136b741886d6d737c918f7d127cdd10d6facc1e37ca4876b3a1bf7d2a20f7c9167315816a0242e5600d9dc0b17861cf2f83ccdd0aa1f7134d3235bcdd030bfde150fe0c505c99a80f745b067a222d5f92824eaf4cc659fcb9c8598dce31bb485116593b5d165562a4d286aaca00bfcb40bf64010bbcdff97e174463cc01cfeb23e9f8ed20c496ad25995b421512deba83b95190b2a3c2c7032f608d07147f176bcb30feb833b6afac3f8b289d0ad7cdc7777062c2b75a4242fd019c4fc7aab6995dec32977d8f24c2a4401b04d5a8942020bd2af654de446f71ca3dc5d2b4e5fcae0346716120b4c8ea510493fb396bc1127d09f104eb1d127e8abdf11372bdcbe306fa82f9cc5f69077197519a5e83cd853874070417928a3f869a9e5aca2438930e1349ea62512c26c13972497c77e9d6b0e4cddf99b1e467349c1c7bb86c446ed283221535ae778886961da24e87a9d0158bd9e413385d206eb66f4df6f534d95221e8b9d7aa094b9207f8a4b2e29a974fb49047a9af5f3cd9f35347be3f03b08e46d9abbe6154c05549086b4d54668f69f10bc5377af638a8a3beb7233297843" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a29766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfe37e43dab920446abfc985b78bb56c0ff753d69b56633f9e111a7d15aa093aed1c82c85c0d011d5deee5caac115f4e041f6462b8e597b6ee7f03d3bfcb33a9a6d884e64b4dd470ec975d3d2a5761d234f94ca2548008b31a743c289ecf4ba4c5150fb82c2f7c0d5063922a384299704af0f7d474b92301b78c4ebb99caab99d85455fc6a6a57d855da1fd01ad6a1fe2c191c4f0bffe320196cdd98821f4a9d682cfe43fdf2e8455865e53c27dd8bd7d64f4cb054604b8999d7bdc172cad84f66603b33fa68bf27e4aafccaf152b623bd43ae7357ca589aee7df1a0c176407eadd92477fdd0c02cb1c8340bc2f1c1b662270d96ffa7d876538debfd89a4a5f30a3fb6bb0b7b5e91245686f54c82e57dfd4e2c229285fef9d1fb5c93f3143268fa0e0ad8ed8195bf1ce7bfede4e1d136ec8b8aa8dabc6adcadb58e1f507aef6f02e487ce893a5c21b7992a4d3dd0304a2558775273c76256e4f6f1d657eabfdb8e5cab00b325c85b6102315fcd079b4af2cb0ec5cb90b4333211071180e1ea49c9771a3a4e7cf8a304c06a2c603327b75665aa302f2480b0d887005da998e8f3fc9cf65ef1fbc893ac9ca761d0e6aad7e1d824af368ff29c5ce31b72bd54a82498d39e28e8801370bc4883537ebebc7da76ec442b32c321c99eaea879a86a16a9f8d1b62e8c9c40331e5a0e516a0c730ac19ed0ba4aa063f230dd5e7e9273736b33a2a341eff9ab269b4e36d20edc6931852238348d2ac5244a23fa6faeb22035b839289753f278134d5ae23c3c5b01d7416cbfbd8a828ac9af90c5c38b8285614182a6f6fa599efb2820b336d57ba14bb51e4bdf8585aa922d65efa3d92f41175549eaa1c1ab58b5aef32f6cbb2af5302f7d9723ebe72b2c1924d7917b0a1dae1fbb89edd2acb569ffc08f4435d186a446d46d71c87dfa6b8d31c015463cbbcb5909c19c22448651eb04c00f9e637f9bb60fdc7fea490c65505ea357da7f8c81cb937525dc8c008121c395c7f2c275287a34fc477635f8c46c116f345c82d8737de3f177eea3c8eb3e8464446dbbe424939f96c63c5bc0a47f49abd470dab155a5333aed0082afdf0cde1ef0b003dcaee6343c4c26dfe9d70d9d83a4c773d6b5c21968620fbb41aeca1717cc9924906abae62872b0de93e505ba65e07d94012893cdaa23f91dc2d6236b4f6df4f7a7dc90b7c11f68b8bf6370692b34b978d710078195e4376e3ae44866a90b59e9085dbc9c1e848b215ffc5af46681447bc1c04e7698d6d0ae0a8f55754e2406bd00cb86853bae58f5b8a4481384c6549eae0aea15a44d8f5245e0cbca5f86c12436c3c52af8d769818c7cf0590d807b139e5debdbb6e5d01c5293da8d0c82968a9a13191befbe57d7a5ab350bc2bff9536f529f9a8dc670f4f4994772abd2e6db7985df07adcc3c9772aafa7a1bd2cc011b3740e88e573e5fbbf83de119abaae8cbf6114130cb0a2f285aa93b3fc70067d73e53ca72cd9117794fc70bd5d4e0c1e8ea8bb7e2672a419ae09dc253ddfe10cbd76d7e7daa425f341d104b1c1ef67278b2b61e4e9d5d1300113e800680548676022ee100b1d0f24ae87121e390990030cb8cfd1d12bfc7951a7d0a296c6e8a09312d68240731b398a508ebdc0e71bd2a08527a951f21e45459c86837bcaa4985da02b5ed7302c4a02ab165b4735e5343d5a989ccaa12969ea13f62268e801984fa9faa61a6178965c2b11b0e69810292ddab96c6f7cf361875ea94af5afe2d769c58a245972d75a68aa473d8f64ab09c04ad3f29c4284c4be192214993a3c0331254db6df0394fb49a677556d915538720b5f7381a5d334cc2086d8c878492db3d3b71b8cb098cb868b11127dfa503363f36d9c457cb27eb18982db0bba8736758f674f251c1959e66ca15d8c4c6465181a170b5185575af2bd26bd8a67c4409c30e92d34e9f2f2259c04f5f8a12f76dde86a3e3b53b26be2d3b8fbae16891dd44da865109cd71e250d22eef5cfcf640a3c7fc47a6e078c2e40d55a94e918d8c6367c24d8e8fea1c8fb52d5f20e24860011ce59f51e3520a3a16bebdbfbf7874ee3151ea8437766e1d84706c5349dca60879968537b89258ce47306919cd6418ec1bb7973871387f4acfca73d5666501bf395343395b63ae97ec968ea9cfd0881cc774a1174722734dc6440fdf62bf2a9291c131fc41580854b58eb89622463d725476ea07d147ee8a294da23cfee7bcf81d8d2564049bed7f8e1d89de48abeb91daa44462ec301c99786c12af61364cad134df0ac15ccfb7581202611be4561924e14294cd9aa18ef254f497e857c8ebda43e6384f48713db30a5274d3ebac54a891306fee200352f4d5c34ace496a164bea30bea8b600e74c30c77db050c0deee1fb357e91e427bf6ff745e50a554bb4ef2c51fbbc0d16cd5e62a0741d64fcec8bf1df41a5d0f30af55e47ab2c630c86f3f3e2498f9618c6fc36477ffba43b592b859b7eac3896fa1a852c0e46241a671c413dbfb896a4186655fb08c8dfaf68270b3d1c16bd16ea1e346fbe740fe46658aa87aed51433ecbac67c864a437d76913b181b2f5a833c276b7cff0165c9a32a930f1382d7ae8af63d32096d844bd126e664daebb736e558d4e1c593347185f9b4afd87082e3eb322733e6f9ef0ac8704465dfc3ee5743e4d17da9aafbab9ac7d8bfac4ed318007cf61fd0c5b0253b9cf180e7dc1f208ba18ff073467607b6f7df14e1e330dfd07b3aeadf4b7c0d65cd374865d2e803f392b60590c0faef3a68179ad47a02dbfa6057ba8d188a1cc9467b5ca8e1e0c308c98bf4a5a14ced96bd714e8ce05a37fb2c1338f2f30824e35a45d0e193e53b711b708cd684a73d0bdc15e46745b99cef67daeab06787cd90fabf72818661a79394566c371909ed72e3c6c8f27ecdbfe60854cc97b2267a1071aaccf807d6770875d175af7a7c0b3ae8d59db0baaf63b2ac9bc05f45a9194a183a6bcd213ff6fe66743e0aeeb3823b186bc02db29abb2652dc4e18631697f5b237b423f905bbb91efbe7083852ba1e13e294e310a857556e5550ff46c5c7f178f507d1458aec3adac18d22b7f037f42f2bbbe60bed144b6a84509c24e34ce9a88cc4054bcd457dfd87ce4e2f20f89108998e1fee44848a665860d23522ce4b5b0fbe572ce9736b1b97a3ec9df0641e758cfe4bbb5141701ba230db7b22a00940bf510e5d4da8b0ebbe67ee493f12a3a45765d3e83847b411a918c48a0a21b99ce585f11ed0da994512596a1ff371d3c968dc967169a3de7f8ea260eed150029c43e43a164fbc10897f7aec070d4def9ba7b18e9eecde5e2ffc841f81009e14113ce94401077ee68298793fedc71c8fbccdd2238a54c65e6f0f59427a16595a5a692e19ee96d9b329577f92ad0268a88bafd3c032f4903bc28eb14dbe42ce297593c90a10a5c4f517735668273ba814a8c01f7ecb90167d5c8d0e418802ae01855c738a88fcbfc1ac4eba8a0897cb8566e22ad4875604936aeb9515ce040bfef0516d8a06971ab3aa65800183b4152344302989770c603793b8ab8f18c963e04d20c09fcc0701ca4b22429884caf3aaa2aeb1b36f10ee8cd63fac4fc8c534ca3985fb29397ba17b7b1e7732385d44b4dd5f0761c85c087f4de8a380344648bd31f2ee3e6efa61e1e657fcf111a0bb844627d7869e69585b02890899aa765af76ae46708f01dbe6b5290a742afc4132119db0c62a1fbc24ad14a076ed1461aed511934b172ec965934ab49f27bcb8bcf98f43f19e23106c9be4e8f32887ca16f2b958de3dae9783845594e9d8af796e1597160e9140e7202080272691532a85c0e70497ecf543de47cdf19465d4792786df2c778bf3af8e83decbc92200f519d81ffe248eedd4d168b3e583ed3bf0d6b1d60e6f8f098965379e356d7566e34981411ec73f521c0c2833a2cf54112eba1b02b1c9a66bf1a25946dca6994ed0a2b61a84ebba9f3ff2984cd2ca83b8d1e9ea4417c1e0ed2630dd9ff4940d0b104783e22410e8c9519d42b08bf96ca7cdb28f430ec00733d03447adc2a4c405395213aa1a2676be1d2f89aac6f634d68432a04ec37a6578115f1f4cb1f2a17010d347a52a278bfca789c7a686a7f5401bf6f09fec243d9f4a0757315caee470e1103be7546c68c02cd382039ec93e7de32d35a6a7c50ec77483cd71ef62a1c4a4f9778cf4a07cdef3855a2e66ecd3b2b3f1fe7bb70556d41c6b0553530092b88bc1152bc9e83c438ff6205ed6d4f51c4ab92f2cdd751ed3c6131e2ed2d6fdb4912897903138f9721c0f07d7932d80322411986176acdf37ed30b0260f4e539067dab7d174c718ae5150bedd36169bc5ddffb48941a0d81e60c04c5f50942a3c4cac04746a26644e4a087624168899d536c803ecd020c7ed2672a8c9d94dab886a264aefcab40b7c83693f0d00ad241d7d1dddd4313d870bceca38c8d0af05202ccb355fcdc26feed04314addd1db98d84f258cb38f3758cbd3c39e8bbd47defdc988085a7ed15e49215778fb49d7d4e585c3b6cb1aa01e5a504ec4b1cbcd8ec91f0752032ce4ae3b636d89693550967d242be9837472df1ae3aa42f066c2434303ff613892a54f761a7ae5fe58d7468fbc2e3a988d7a740f76a730e161a7aab25338101dceb7fab7b9bd0963f7ad8c1673740098a675b9e7e300fd7af6057b55eef0ba6dc4f741f519cfb797f0e620c578ea217405aecc79a1f63d7801103ea9710d9bae5300b038bf57a42be2b9edffddc2e5e0566d68d57f73ae17a4972551f84ce52bbcd8bae7fb3149c75d5be2016d46c13ce37c59821d916510bc8e5801c4958029fc7f1c5d45deb3f7a274514e172892e2c8a2aef39900059b79b4741878a49a21beacc6e584bd47d4e9a9731b2d5f96b6d85531d00b86fe5453461ccac8e97d4757a62fcb3754cb92fc7a9fe4a0149448dcbe7933454dedd9a1ced16fa91c208c1e4fe14747c269bd4bde5072dd17674d5f25533bf0395bbd22bd1e5ac8037f6adfe050a089f3d8055ac52ef6c91e88094433d097b0c89b38acec5f2e6f9d58d5d861a53a01b7eabc006de4ebe58258f5232ef98e399da82b502509031f6b040244766dca50b997f8dce5f1c81dedbccdd60689e3af5b898113b14b1d24f3c6acc285ea661b662a8132aa60b98e62b639c21bc042150fbc4e3eb16537e3a0934a596d73e0c0944d467cde2f1f007f2" }, { "category": "nonfinite_sensor", "entrypoint": "bundle", "expected_failure": "nonfinite_float", "id": "bundle-nonfinite-sensor", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a2a766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfcec22dfa106594c46603eb3a65da04b992c665cefd1500be5081e143fb955540ca6bd163ef468dbfd0c45c286d933f25bd23555797652210916a4bde18954938b134ccbe2ca2ba17fde5ab18797b73c61606e984c24a1231f959de9939ed17226c807074747390afc8cfbedd82ec4d2f6355dbf3131f06fb24156ecc0d25b34bfe442d3187ef552c1fa32e5256694981a18a1dfc6c3c7dec783e39bbba03bfb4d082ac445b99197e01cea24e717d01fbd8b594c8dfa4890ea1930f2cded1fbd94435390ebf52b1904054fcb0fb41d15d640868fedfca807a3939ef2030908bdb805379401b4da2171922b20cfc2b9e842f8621a51b6b7efad626bedef624e70658f3ffc8046fb5d50f71ef3b92877b130f47ba414dc04304920de9752f306419600bad70693d6ece99272614c6786287c0f7e9fbaea85dd24de97ef6e1f1c780b518ea198c40025196a4d16dfacc8febd21ba3c5b6d46efa795cbefea61e416105cf73ae18235237535b16cc11a17d1a92b553d4e9052cf26dbeb2c6018a70b264c7033b5f35f9057d311608357123356ac00d0ca98bf7a3933b47b60578189a14edb5172d6bc9fa6ab9b910f42548109b3b278e1849585c0f9cfa878ca459a9d2bed9a9a37416f8562465f8991ac6df6b3978f120fc1bfacdc07d37bec34312c8df03403ed95de38a29d9d5bed2f8f2fd5183c62a47f1e773a6ab17a293ff81d65fad4067ed0a010fc6d96ba2cb3ba98759fc65bf1cd52731256e9da5281269f7584d8caa22507e0885e1e6da8a6cd0ade2137f3e69e972d23a9b4aa835ec685fe6c60c1b6826d32a7026205ee68550357946b9b94bdcaeeeece2c78f8b99f674dd0ea4068ac87e942e0f4758413fbc04cd44a4df99673cae7b3b04bcac66f5b134a69829db7022b5cefe3efddde477ef85b78df88b6ead21b123d830a770eeb43d3216ba03cc722be6a8c129e48d43dbda2df0d0aa2659969f2ec402d2e5d744027bb60c5d111f7ed4970177db30434058820882fa4c5aaedf6f4841f85ccd04176d929b6222f99fbdfd5b7e4a0254dffb2597bf3e43a68c115c9b9edc8bee449d6dd862e46697482049faf489bd04926d9b7416eabc3bbd2698b0f723cc06def06516fa2aff721560ff12cf9c05db34d46d42e669182eafe82974c4237b0b7622c0aeeac430aea603638fa1e28d9fd15abd0d89032c0f66032f574fdf687e34b85d5f3e762aa49cd77cb744b3bf06edd2fb3af4fa4831a5a8dae3a369c9f60f27c1bc9a3c370ce292a64b774d3589ca374ffbb4858ee27d4e0150af3af648620dbed446ee2d649045a716269cea7e16dff9061aad5b9d980a859742977844317a16e418e32ee57c6aef2c00a1346cf6bb6b32b248cc8d169f844f79c4e12b9e7c5745f97a71ee2e0e6780143637adf5b64d9e1cae0791f4cddbdc3ace72989da7b365e963568e8a65018b19ea13a700fe92379f6aac465262ec2ff7d9d25485d1fca0613a941d9cbae79a4cb07f732d2fb90b3c1943fa689e8aa404375bc145122798486902e4b9e3b66bcb5639b865819ecf1c4ef2e2565bf9072e53b4cddfebe1e8060ff60cbee9d2eada49ed159256f941df81216a05b072e60a5e2a74113277a9b790411c9e6d83a5831367d656f9156c4362665227bf9fb853c6f7a7677860a2848d55803882284f66212284aa72f29c9a8b32ef13846998decf020a630a64fdc888ed8443f87ea69190b457ad973dc8903de7573c05b1b499048048b0e9d02ceb0291b10bbd97d417d68f5b1e4177cb6b3ad4e0d34223402d4e29b8dd21c9c0fd8586f8b7613cb51ea2510fde369aa0e7d5ebf90bb532ed04eb5cefc1fb7148d4b4074336ec39c31a7a73231166db81e0b9f471c7c86263883ff573143781ac67cb58b43ff979d09f967b733655ea64fa9b1881aefe32d9157b6815c6fecc6d4dfd182d37ae974110093b24114d161e66b18812e73030384b7520395dc66db593b083fb4d5fa4af52a4db6fffc6a84d20fcab65d9b565419b332a4951afaf146e87982fc8c2ee41f5743434147eefda3930aa5478ecb9a3537462a90355fb04f060c4db006acee45bd23468a653cac3c3cdbe5f7f6f1c7d930c5e431cbe6128ae14c506d0fa04707c6436419817ce950861dbf2372c5dfb929ff8bdee7f822e48bc8b5d5946609546e1ce9d4af4f542eb3919f9fe8866310880280b0200fdcb2108e1b3af9074418d1baa1977631527285ae38e1be131311233da37ebc7eab2a9909a2fb24f97d5cf7a74ff655164bf234e0745bfe0769a34eec78be7334c093136c2c2edf5ca55337d8fb88bc7a5bbba1f1171d9cbf348e9c3eb12f63375a2250fd939524d26bdbea8ac692884a77379ff44838d416ed8e178d141c147afc3ea3d033089d039778878f3e306be69e490e40ceccb62571bdd7d4712296f9947b35e7f0bdfd115ce7c49616f7af89ebe56c7d4023af2f3da4f1177949031b09d00c35f66786ad4ea4da663f5a4899c7ab065673094e76986149c466bc4364320fd8e9b2d5b2fac85d0c95b27bbfcf2d607998a685f4cd9280679a7f929ed22b1e730e3e227520eace6e77436a313be6a808626df13e7d288263465f571923a1e034695787a774c2569564420b43da692f0a717392ee205f302235bc48605ab8ff4376f2b1c2385f9fa98c862c0a3d50737fa20f8a1ccf96cc3dd2b2495542712d950878f2e953ae96f96163379e399fc281b15d27981344ccdc7b136ac5539e9a153cb6ec5fab6693b5f57e3f05bcff260d0d2059289560b77190e246102f2ee37bffb1df799b876b69db34c0dbd514ada5fa1fef77c7b86d152b200c5eb4428254c5ea1c0215ad43f1c242074d1879010707d68ddba7491e753942b4dd24c8927347af5b5dad3eb579539942469790ae550f36e533fd2bb917efd03b88ed5afe5ac60c608ea361f26c1067a02185d6a821d78c1e9ef255775d7e234a59814bd29e3855c2a57a9ecfd9873c0209da4abda3db49ca5198c749bf98728c9e4348f842a9a547f898a06ac355e8916c3238c863070537a3a44e401989f5309926fcec2ff2eb2c4c1815e32d8f462c4530c9cc588bb0e3b42d8f12dc1ff379c5f71fb7f8006125e3cbfcc8a2c3bf7c3a31c4e8b6a905c055e6f67a0bb33ca67a0f1e228fc60982fca6fc741bdfeeef10b102f52660291e0622c598e8c360eacfd2b376f3fd1bd5758e31904ff81ebf97260f0b7fe003e0cb5adf9ed828d27b6ae1f36f80b3f638bdb598683af7a0303cda9362a4978944f521e557439c7e010a407f54498f6d70cd797143834b1b1c11c3b3ba15eb0e9467fe6791b3e14fb53f7e40ef6adbda1284f1958d6500325137735bc2155b3e6dbb4543da2617ed1538dbe9512cdf999a11aace16d756ae198e4e58470fe33675bcde3b0885faed8f542818046727cd85e239e3d3f0bb66b6b7fef35b91c1cb167f33ead66ee2e594b441deb5d14954503f17f2f7fc4bf872ad2f14f32ef8a6f5c3dc862a117d258290a295f988cd6b4cdb47b8a6d4b86d07abe3f735737201c6f68ce363f25f8439debf2896f52707a4766a81b3d67689f3fed3d9193e604c430a8af406ecf7856e267657afe3bb23a9641ce9d55d842559d5094a5811e98d5bab0354adaba85b53c4f9731fee890371a9d4924a1de8e5d498962fec462ab4ad22123268fbb0a822cc020b407400741160fde9679c87119c7655a233a057bdb4fc48ce41a958121693b7540c1c904907e6495822de7a44ff2c1e366933f4611040de7e32aaa1e901f01ba6e926f9f93b665b02fed626467fc302b6ca4dfbf6d693586e6b939a38f8b4f2be39f1f93b65be19f68f5dc4c49f774532a41dddc0cced4365b18f84f127b07f360a20edb1b7b6d0a84aa240a67d0a3a186bbbf527954ea903e00271ce6038231c80c0412d3f5beb21c24992284968dd87243e30164010d32abcab2af42bc1d321c7af9fd270ae8c078ddddfeb52acd1a6e97b0349e6f99c84531468a3ea28c112679c5840a3858701f51b640d4dbe620c2023f4eb60418acbbf0789daf083d5460743c94d96f98daa799d69331b0403858006380d0b2fa7a386de7b45f7f534a597beaa37ceeaa2a62bd14a82547d501c6fc5eb7b34b2272e44f35aeb93fa26761867a59801925db99e4bec579784f6bb50f94d35d95e60123e2a98e3594e960303117d08eec860d40904a04ea9bee62f937b5e648e170239b8760ba60aacc322967b21b147af3ac8a0cf84622d8311c461e332911932361f69d077cd45ff0f6178a6f39787d606ffe8c4c27e7d9058ff9f72ad0a415f33537939eb455a53b9ebdfc55f73af726b1b26d78f737f0dc7340b16a0e4e4c6ef5e66d78283e94837977edab9f4610053bfa01bdd4ed153d5ed21aa85f4dfb38dc55e9c8d4828edf047e6565b89109386f9eba57006142c14744b9cba54d" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a2a766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfcec22dfa106594c46603eb3a65da04b992c665cefd1500be5081e143fb955540ca6bd163ef468dbfd0c45c286d933f25bd23555797652210916a4bde18954938b134ccbe2ca2ba17fde5ab18797b73c61606e984c24a1231f959de9939ed17226c807074747390afc8cfbedd82ec4d2f6355dbf3131f06fb24156ecc0d25b34bfe442d3187ef552c1fa32e5256694981a18a1dfc6c3c7dec783e39bbba03bfb4d082ac445b99197e01cea24e717d01fbd8b594c8dfa4890ea1930f2cded1fbd94435390ebf52b1904054fcb0fb41d15d640868fedfca807a3939ef2030908bdb805379401b4da2171922b20cfc2b9e842f8621a51b6b7efad626bedef624e70658f3ffc8046fb5d50f71ef3b92877b130f47ba414dc04304920de9752f306419600bad70693d6ece99272614c6786287c0f7e9fbaea85dd24de97ef6e1f1c780b518ea198c40025196a4d16dfacc8febd21ba3c5b6d46efa795cbefea61e416105cf73ae18235237535b16cc11a17d1a92b553d4e9052cf26dbeb2c6018a70b264c7033b5f35f9057d311608357123356ac00d0ca98bf7a3933b47b60578189a14edb5172d6bc9fa6ab9b910f42548109b3b278e1849585c0f9cfa878ca459a9d2bed9a9a37416f8562465f8991ac6df6b3978f120fc1bfacdc07d37bec34312c8df03403ed95de38a29d9d5bed2f8f2fd5183c62a47f1e773a6ab17a293ff81d65fad4067ed0a010fc6d96ba2cb3ba98759fc65bf1cd52731256e9da5281269f7584d8caa22507e0885e1e6da8a6cd0ade2137f3e69e972d23a9b4aa835ec685fe6c60c1b6826d32a7026205ee68550357946b9b94bdcaeeeece2c78f8b99f674dd0ea4068ac87e942e0f4758413fbc04cd44a4df99673cae7b3b04bcac66f5b134a69829db7022b5cefe3efddde477ef85b78df88b6ead21b123d830a770eeb43d3216ba03cc722be6a8c129e48d43dbda2df0d0aa2659969f2ec402d2e5d744027bb60c5d111f7ed4970177db30434058820882fa4c5aaedf6f4841f85ccd04176d929b6222f99fbdfd5b7e4a0254dffb2597bf3e43a68c115c9b9edc8bee449d6dd862e46697482049faf489bd04926d9b7416eabc3bbd2698b0f723cc06def06516fa2aff721560ff12cf9c05db34d46d42e669182eafe82974c4237b0b7622c0aeeac430aea603638fa1e28d9fd15abd0d89032c0f66032f574fdf687e34b85d5f3e762aa49cd77cb744b3bf06edd2fb3af4fa4831a5a8dae3a369c9f60f27c1bc9a3c370ce292a64b774d3589ca374ffbb4858ee27d4e0150af3af648620dbed446ee2d649045a716269cea7e16dff9061aad5b9d980a859742977844317a16e418e32ee57c6aef2c00a1346cf6bb6b32b248cc8d169f844f79c4e12b9e7c5745f97a71ee2e0e6780143637adf5b64d9e1cae0791f4cddbdc3ace72989da7b365e963568e8a65018b19ea13a700fe92379f6aac465262ec2ff7d9d25485d1fca0613a941d9cbae79a4cb07f732d2fb90b3c1943fa689e8aa404375bc145122798486902e4b9e3b66bcb5639b865819ecf1c4ef2e2565bf9072e53b4cddfebe1e8060ff60cbee9d2eada49ed159256f941df81216a05b072e60a5e2a74113277a9b790411c9e6d83a5831367d656f9156c4362665227bf9fb853c6f7a7677860a2848d55803882284f66212284aa72f29c9a8b32ef13846998decf020a630a64fdc888ed8443f87ea69190b457ad973dc8903de7573c05b1b499048048b0e9d02ceb0291b10bbd97d417d68f5b1e4177cb6b3ad4e0d34223402d4e29b8dd21c9c0fd8586f8b7613cb51ea2510fde369aa0e7d5ebf90bb532ed04eb5cefc1fb7148d4b4074336ec39c31a7a73231166db81e0b9f471c7c86263883ff573143781ac67cb58b43ff979d09f967b733655ea64fa9b1881aefe32d9157b6815c6fecc6d4dfd182d37ae974110093b24114d161e66b18812e73030384b7520395dc66db593b083fb4d5fa4af52a4db6fffc6a84d20fcab65d9b565419b332a4951afaf146e87982fc8c2ee41f5743434147eefda3930aa5478ecb9a3537462a90355fb04f060c4db006acee45bd23468a653cac3c3cdbe5f7f6f1c7d930c5e431cbe6128ae14c506d0fa04707c6436419817ce950861dbf2372c5dfb929ff8bdee7f822e48bc8b5d5946609546e1ce9d4af4f542eb3919f9fe8866310880280b0200fdcb2108e1b3af9074418d1baa1977631527285ae38e1be131311233da37ebc7eab2a9909a2fb24f97d5cf7a74ff655164bf234e0745bfe0769a34eec78be7334c093136c2c2edf5ca55337d8fb88bc7a5bbba1f1171d9cbf348e9c3eb12f63375a2250fd939524d26bdbea8ac692884a77379ff44838d416ed8e178d141c147afc3ea3d033089d039778878f3e306be69e490e40ceccb62571bdd7d4712296f9947b35e7f0bdfd115ce7c49616f7af89ebe56c7d4023af2f3da4f1177949031b09d00c35f66786ad4ea4da663f5a4899c7ab065673094e76986149c466bc4364320fd8e9b2d5b2fac85d0c95b27bbfcf2d607998a685f4cd9280679a7f929ed22b1e730e3e227520eace6e77436a313be6a808626df13e7d288263465f571923a1e034695787a774c2569564420b43da692f0a717392ee205f302235bc48605ab8ff4376f2b1c2385f9fa98c862c0a3d50737fa20f8a1ccf96cc3dd2b2495542712d950878f2e953ae96f96163379e399fc281b15d27981344ccdc7b136ac5539e9a153cb6ec5fab6693b5f57e3f05bcff260d0d2059289560b77190e246102f2ee37bffb1df799b876b69db34c0dbd514ada5fa1fef77c7b86d152b200c5eb4428254c5ea1c0215ad43f1c242074d1879010707d68ddba7491e753942b4dd24c8927347af5b5dad3eb579539942469790ae550f36e533fd2bb917efd03b88ed5adf1b77dca028f381170c715751f024077841c6590f18e050e31c3ff36ea800cd62dfe9a412c46a88af39623d7248fb8aa9625acfda918847b95eb803bdcf9328e9526805a76869f07ab22448005d43883893c7135743412f90590d84b48cb2fc3c2e4059ed2d7814434d9c554c91514848585bb156219cbf73adae26792513ef97e811d095f3bb5f0a1313ffff99213418a76c1441d4d6e67b2b53d9f71adf1d103e1649015e06ed07d8ffaebf10e0a7e4a7e1190e070730880da2d43b4e54a3b6e37deb34f51f00e1ff08ee7f8143fa1e0a7442a11b7e2e1f5e38126bea1112ef118287884d45587e5a1745314c9bb2a2b05608c2e5e1f486232ddeb1db314ea488afec713c29c1f3952bfbf97013f33e646f1b00672e662301202ed53ede20fc79bb0e730575955d64b5a7e007122a73c05bde894b6553cb97a7fd659b1958816c5c3a8ae15adfd3c6d72f19ae4f78a7eab256a5bfec8ad8c5794f2f455bdb242777cdd447286252f09b679342ee163a25104a906ff3fb070e53452445116e150139c205ca028763bd0a28565cae92e3eee97795727c36db41cd8552e034c5196dcc1b0dfa87ac67553e7dc7ba329784d3c3e1e6f6edb322e249e4d93bdef8d6715683b1e26a51b264c448929ed279792d732c976b0b70063cf630fb974636de526e234980ecc9c54c359549a5aa88e9c1a90ef8bbf3a4bc6eb9dad2d4c9723f0e6c521179d7a0fbcda8667638875c2f666ae4ad73b723e97aa09823e9f71ba515b4a590e6cdc967fde7115cb734b223f77248a189dc8f00797ce3971587740c7df049c727284832895aa41a2d6e7748f3e0a091c6ee5e32cbc1e9c1003ad6d986a919caa6ebf3eff6272669a3e253ab9dbb72a7174dfa6b439b8a4a7593de3851992876decd970ed9c4149ec2d083947cac7119eda38141af94e096006f46a9e25c61f73573b8ba5251636c8bbb782bbad5c7701fc9d3e330c01e20bb81bc91b3820d7f0beb70675813a92928dca2d12ed577f4c152adbddb6aa5fc742634b23bde93a08a7d860adcafab037b61460c7a7308c7398845d29349b2da4911b2c60d48b103354770a42b051d5bde82e941f3b46f11c59f5fbfd788684242b42606e3a95e859959cbf81969e31ab19634b06749bcde2f474776fe6b54464524d5347c1be78e6901b6db215b4745fcd16c3fc4cb9bd1e347fe47c1eb3bd37984d6091466b841c25de83b5a6dd6e7d4f79ea5e9a8546d8fe1953e5b28e3293e0056f6031decaeb990c0dcb0c33db8ad443c51fe2ad02cc6627999d6bf639edbc4d5a02d9347d1cc995b920cf6114e3233461120f1335ee622aee98036ad45fa9e545d5307d0522277ae2974e28cc925b8fb3b43acba202bb3321dd98be54e276ceb3fb16fa3bf62cb4b41f7aaf78b9a16d53b061080f133ee4ec266a697edf91766fde828e5d0e090be157e4a9b75e7d10cb05eb1109a67a8147f19799ce9ec2492d231784b9e892406e3294e4948c3f5d0705017df04d1a4a16928df8f26b188aecd7d88dc661a13d005e22a55e80d469d2c3474ea394fe123c46a5cc484fe703794e0145e50d218da79478c0e64f5cfda6ceb2d78c1336cca9e2866e377122712be73b7f56808df46b00ca56cb2ce9182cbce36961478ba07fa8fca3346598c0c3d0cc528b7d1b1c34a4b8dcf9fe68f963d33ecfdfbce54c760bf322a85995f079dd28e0c666b375498f7fafb44b6e46c4a2df43552d46f01d3c188cda" }, { "category": "float_grammar", "entrypoint": "bundle", "expected_failure": "empty_float", "id": "bundle-empty-sensor-float", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a2b766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf1ef015ed973a794093ae8bac525ac0657f406c83ea17bd3704420c7ccd68e5f7671f90f5cb9c7e1b5556366164717a6610c8d54d8477cd9d599ac92f59c1cd0db5ec3afa4d4c3730b1a94a7d5d2b1856ddae39b0191456bd359da4719fcbc02e2d1ab54cf0df586e6a87476a092d61abf08cbdd9104259c1eb9301e53c80dcb487e71cae70145fbb8b248f73425bf70cf1a31888572cecf7da25c789ef9bd558ddb7ea937a188f7684ca6cbb9ef13232f6c4563ff1ab50536d482a5007b4452a8072e2861e3820e2438914e0c855232c80ca29cb7220b8ab8c5cb65e68998a23ee19b789e6a5485681ec8b5afefa769b6ff6a3a9a3adc5a02e090890305c2da8dab1825903718352247a01a7094ee5845ec2ce1efe865752b6c7c3c5a679f1feb1b7ee2638a34882e76075733a97f5b45184aeb22d15b7292837469d82bcafe425bcf0a284a1fe827be358679f7a528f0ef8a8bb65bab6784768b8c727f1432ef4fc3e44d1232d27b3337234df3d75e0dc66b3f2b885dbd4b3e2ad6ed8514fd0856cc264ba0a35e28660dba9c6f3c04892001654af2642cb7c27239b403324aefacd54a64194c8a25a71111b33f50ce46a291085fb2003287651af33ec13d2986234645742f1a5f137ad4b273196a025cdd086b3dcae46919f1d2bc14692503730f404d87ed3fb5de2af233fb47ede99443af33bbbba9aee644012c2e92fbfd0e4843854eaeff3d88eb41ef4dc7eb203cc295fad8484d2f9ee0f9deefa5d9ad5a6928a4d0a040776c5513bcb914c626a15b0f0c37d1b876e79f98edc5ed21cea23800037fd2e0630c080b6bb6fc7c1adec31b0b6ccd34965356746e4f6770c24621060545eae2f9ccf1a7388fda0ff76f1fcda5e35e53edfdd56d4a3eac984a5075bb86f331577a551dc91eabfe5cd1aefd9423786bd4d3d00360bbfe798b70a9a15bbf6d9922a599be07b902f10fa8c05bd3bfd3cfbbe7cf9973e52e180ed480ee276e7cb7022eeb0a281ff8338ae7e51c417a1046a06c702ebc467ddff1afb91ad4476ffefd8b3d6199b3fbec72cafc7a97b6a20b490de5f1e35e06fb62db369df2f0e2b435c7d917091418bae71e340396db355ca16fb8c615b7ef4b4965e66b738694894928cfab6faa68e7536617c1c8e2c8344d69fb3a5c8af33557702ef68e58cca26d2f6c5f843f736a129979333ecc546eef2bbbe8e0aa0f44ffb3d3ca1c712e2d7e4f0737a92208c825af29a1b94c9b71b846ecb467b6ed1362c76c5d26c8c94fc6dec289fa47de680a276d125ad8559c82379b737e966e0fa4fb44efa99a0588b9c02d10cae39ff8bae9e2471cc0df19f4fd9941b64b8af91ba1bdac7cf7585e61a4abe0a30c43c1dbd0781f534fd95c559b4f9dbe490dac48daf2997dec5eabd87d2c93726e89aae5ef7b520592583805252431a870186ccb1109d97ab8fd34ab4f6b9a03c27a94a625f251ec62a1b00b28dec6d74e1f8ec26a0aedcb4f78091cdd2eacda9240c2b90d1a30f12f03d7e15ea83fe5495de461c21168c2f5c677f2fe3929bba842c3213f61c3735c4344119ec53633e14beee085b41bd3a6c47d31c8c2957336be4498f18a3e198adb81949d04c6344a75d1ee0f2151de175bc0bc74fb3b6051485877458c1088cd4ff6c46aa119b370928fb2584ba218dd5084aa0df1fb8ce901e02f5dc72774715fecb87a87d0c9cc87f0b64b2cad7eaa3fc6802e536fa7eb928eb75b07d9c007ec3f657fe874d5b6ff4ed1263834e79821fc7ff79e7a6ad351f4f6807ae6c4a183502fb092b821e05bb0ac4c1b4f9a56676e76f3de146ae8d999128d554a0e2177a17fa0132637b2a7db3079b13f678a516112ea2f08c64223233cb3a9fec61e57cafb2af9010b613d3ba0dcb7fa9a535c82b699ab37ad02f0ea2a8becc152e38f92c4a88d81a3336aadb1532b9ecec7319cff64476ac1edff20348c516c8071d2e6c28ce3645e30c0c0a62c6ce970ee590896e78e86892d7e611ba2a7de1ce7dc9d5b8fa88c4ebd81ad97cbcc27739512e576bcdea8caafe64ade2b5bffc85ad625cbb19d5ded1d1279ad8d6fe43624aff72980704ba1b44c7472aa1b9290ce85afe4ccf7ad8bcabaccfb77ef2ad90563d2c9c539c4a6c2d2cfe3f240982aad3055ae4e3d91fccc36a61683607e843f60016ebc7694076386877dd00e67102b5b95fe16d11948a5a0c6280c88c11e6132987cf52af0b1330e64f3dfd1fbca81557658a4f758b49be571827b94b8a9b44221100466e91f38dafcae0828a54e93ca47dd4c52eacff44df4d3725f36f82ff37190907b0177a831a720dc547d125a3c2edb84d26067f653cc52495f57da398b5291e7b491eb948fede3de6d39dd6dbdbcc6912937c00fd936efa450122ba846edb202b7cf582ac7c13f68cdd37502ab2f0f5abcb6a068b868689274c3949cad1b2535ab0981875ef84136a0b5ba1e707e09dbe0021a059ff9d036988f335af1827ae078181617e3e2ee4c4cc7b9c90259a099781654a499a701967b4e7a26dc3f838b3834a8bfcff5911521f549e93ff6ade2fd4ef41137a3e96a4bb1201df61642996e80c9f5dff7e56a75d7c6b86666c680d9e3169ea2c372e3719512787e4ae1094bc374e0a2f2c868e4043ab16b709338c53f4f3d62ad374cf06e0eacb0e172c07a2afcfec1068e5b651063361bebb5991440998685919e6612e847c105d7de9e82abfa1d3f28382f3d8696ba5a7ae38ca5e12e59d2512f7fdda0cc532c9b50691f2d4d075d04c91e05bae8de34ed4f11d454e2e1a883279f04b2b92941a1d0d8cf28c7d4de6aac8de693a980e779f63cec424d15bd405db89aebc5fc7aba237dfa2a0226f7e97e6c653f0f42645faf1c5059b5b607d7fa736c63ff8d8d138c1b250f3c740b890af9e1b48385873e8e99e0734ab6f06ac69edc125cddeb415324621ee17122e49863500f4d7f3b616a6cdb23f662eecf114a9a2fe0a8dac5f794a4c6909528d8b4f3c40f7b733e941b715ef4cb84ac5bfab57e302637655aebb5d58269031b07f0b6803cebec3793fcacd96fc99a2f27e78c7b87e14c50d3f2288a96c2986a44e0e79c8def23e4bb2187bf993db51f0f93ad34419bfe28638e9659617cd94d951899010a05b0c456b689199a71e90cdf24e47e23110509599ff2bc5562fbd8740666270b1b7172edbf57c1fefc14f39cbe8db1cc26798266e12ea4a009bbeea76b506dc0227646fbff2f658cd5f61f15ee4e4ef01a1cebab7b55f91c0a6fd7014cf9e05e16a6951f459a079546b394697bc3710eb9fd4975fda9923ca78836ed68214394ee0eef28cb70cfe11883f211d08d5ac022e9ca0ef1b53fd20080f446246edc57f913cc2151e4988ea8397b18338e7d6617fa71040c8359ea505dd4de0c980df363979725ec1fc35c1eae8898d72a07197daff4423825db8130b6240951bcae52e10282f70af03f03e0c98cc78ef5fa995ccc43216fc020bba757c5981906911db129f12bc59f0a09abbf66741a2a6a5059f2e5a67fecf3e4d22ec5e453ea3c2a18b99da1d5557b4ea204f1311fca1b4d0ab684d0e56fc6629c04d059a67ff5dcbac19a6af8045d94ab34f2791fc7c0c8ae78418b48b3a3b5093c117b6fd21fe615926a6e1f72d24ab35d2972d5a16f4cbc834a651c4e789e88c1f8a9855efc77ad6d130f26eed4c185964d677d53344d8d4a6646b5b62bf8dfefee9281f3c770fca0c1c87eb4fea2c20ade5234f500e5e21428b147604351be259ae56f0b6f0ef9396aa9aa3d4909f9fca227d01f8dbc6b5a8a48219c56ee7ac11dc3182b303999025b27de801717a4b5e153533d4233177cc0585d721f2ce9201f73efd764704d59e28d4445e85609a37b2e2f27355b4e345bfed14afe129263d11a44dd3c778476dc9ee483bbc4797796c6fdd7dbb74c7e5dcd002a8d00d7aa12d2b624024263937934fcee626b9f42fb1c07d4582eaaebbe2c72aa119c93086935958101faf652b8563ad983da6e59b10aefc3d72ed8e9dfdedfc5a43f5ea27a1240c34840ff2a44eb2e06122dced032ca5b415a21953add5a4a15a4ce3c73c9f95b2f95b04c17e6a10795fafb5b83b4b15115a90783bed783979b7f55514225365d84f14f4caaf4ef55818e81a7fbc25f4da73100bad878136162f1cdcc6d1d353cd8fbfe40f7c12736a7a08c5a7f0b12ac213b36a2ec620cb0f033121d33d62525a2e758427c5e3e23b83b4bb3d0b40929ac11d39d150e9ae7e38f120c9c32c50ebd1b432f82ec3d55a63a46d30dfeb4600a2f459c22dfef3d4ba78eb25b822cbd09f07563f5cc2abce943828a326614d088f28ca9508f9a492f2041cee02c4967d9eff6681d9aae8fa6fab1114874529de18f88b6db950ca5aa9fdc86f5e5d1b8abdb4107551e39f0f8d1a776061df43c22c7c6eac0341a656d83c9ca717eeb3583f25138f1709" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a2b766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf1ef015ed973a794093ae8bac525ac0657f406c83ea17bd3704420c7ccd68e5f7671f90f5cb9c7e1b5556366164717a6610c8d54d8477cd9d599ac92f59c1cd0db5ec3afa4d4c3730b1a94a7d5d2b1856ddae39b0191456bd359da4719fcbc02e2d1ab54cf0df586e6a87476a092d61abf08cbdd9104259c1eb9301e53c80dcb487e71cae70145fbb8b248f73425bf70cf1a31888572cecf7da25c789ef9bd558ddb7ea937a188f7684ca6cbb9ef13232f6c4563ff1ab50536d482a5007b4452a8072e2861e3820e2438914e0c855232c80ca29cb7220b8ab8c5cb65e68998a23ee19b789e6a5485681ec8b5afefa769b6ff6a3a9a3adc5a02e090890305c2da8dab1825903718352247a01a7094ee5845ec2ce1efe865752b6c7c3c5a679f1feb1b7ee2638a34882e76075733a97f5b45184aeb22d15b7292837469d82bcafe425bcf0a284a1fe827be358679f7a528f0ef8a8bb65bab6784768b8c727f1432ef4fc3e44d1232d27b3337234df3d75e0dc66b3f2b885dbd4b3e2ad6ed8514fd0856cc264ba0a35e28660dba9c6f3c04892001654af2642cb7c27239b403324aefacd54a64194c8a25a71111b33f50ce46a291085fb2003287651af33ec13d2986234645742f1a5f137ad4b273196a025cdd086b3dcae46919f1d2bc14692503730f404d87ed3fb5de2af233fb47ede99443af33bbbba9aee644012c2e92fbfd0e4843854eaeff3d88eb41ef4dc7eb203cc295fad8484d2f9ee0f9deefa5d9ad5a6928a4d0a040776c5513bcb914c626a15b0f0c37d1b876e79f98edc5ed21cea23800037fd2e0630c080b6bb6fc7c1adec31b0b6ccd34965356746e4f6770c24621060545eae2f9ccf1a7388fda0ff76f1fcda5e35e53edfdd56d4a3eac984a5075bb86f331577a551dc91eabfe5cd1aefd9423786bd4d3d00360bbfe798b70a9a15bbf6d9922a599be07b902f10fa8c05bd3bfd3cfbbe7cf9973e52e180ed480ee276e7cb7022eeb0a281ff8338ae7e51c417a1046a06c702ebc467ddff1afb91ad4476ffefd8b3d6199b3fbec72cafc7a97b6a20b490de5f1e35e06fb62db369df2f0e2b435c7d917091418bae71e340396db355ca16fb8c615b7ef4b4965e66b738694894928cfab6faa68e7536617c1c8e2c8344d69fb3a5c8af33557702ef68e58cca26d2f6c5f843f736a129979333ecc546eef2bbbe8e0aa0f44ffb3d3ca1c712e2d7e4f0737a92208c825af29a1b94c9b71b846ecb467b6ed1362c76c5d26c8c94fc6dec289fa47de680a276d125ad8559c82379b737e966e0fa4fb44efa99a0588b9c02d10cae39ff8bae9e2471cc0df19f4fd9941b64b8af91ba1bdac7cf7585e61a4abe0a30c43c1dbd0781f534fd95c559b4f9dbe490dac48daf2997dec5eabd87d2c93726e89aae5ef7b520592583805252431a870186ccb1109d97ab8fd34ab4f6b9a03c27a94a625f251ec62a1b00b28dec6d74e1f8ec26a0aedcb4f78091cdd2eacda9240c2b90d1a30f12f03d7e15ea83fe5495de461c21168c2f5c677f2fe3929bba842c3213f61c3735c4344119ec53633e14beee085b41bd3a6c47d31c8c2957336be4498f18a3e198adb81949d04c6344a75d1ee0f2151de175bc0bc74fb3b6051485877458c1088cd4ff6c46aa119b370928fb2584ba218dd5084aa0df1fb8ce901e02f5dc72774715fecb87a87d0c9cc87f0b64b2cad7eaa3fc6802e536fa7eb928eb75b07d9c007ec3f657fe874d5b6ff4ed1263834e79821fc7ff79e7a6ad351f4f6807ae6c4a183502fb092b821e05bb0ac4c1b4f9a56676e76f3de146ae8d999128d554a0e2177a17fa0132637b2a7db3079b13f678a516112ea2f08c64223233cb3a9fec61e57cafb2af9010b613d3ba0dcb7fa9a535c82b699ab37ad02f0ea2a8becc152e38f92c4a88d81a3336aadb1532b9ecec7319cff64476ac1edff20348c516c8071d2e6c28ce3645e30c0c0a62c6ce970ee590896e78e86892d7e611ba2a7de1ce7dc9d5b8fa88c4ebd81ad97cbcc27739512e576bcdea8caafe64ade2b5bffc85ad625cbb19d5ded1d1279ad8d6fe43624aff72980704ba1b44c7472aa1b9290ce85afe4ccf7ad8bcabaccfb77ef2ad90563d2c9c539c4a6c2d2cfe3f240982aad3055ae4e3d91fccc36a61683607e843f60016ebc7694076386877dd00e67102b5b95fe16d11948a5a0c6280c88c11e6132987cf52af0b1330e64f3dfd1fbca81557658a4f758b49be571827b94b8a9b44221100466e91f38dafcae0828a54e93ca47dd4c52eacff44df4d3725f36f82ff37190907b0177a831a720dc547d125a3c2edb84d26067f653cc52495f57da398b5291e7b491eb948fede3de6d39dd6dbdbcc6912937c00fd936efa450122ba846edb202b7cf582ac7c13f68cdd37502ab2f0f5abcb6a068b868689274c3949cad1b2535ab0981875ef84136a0b5ba1e707e09dbe0021a059ff9d036988f335af1827ae078181617e3e2ee4c4cc7b9c90259a099781654a499a701967b4e7a26dc3f838b3834a8bfcff5911521f549e93ff6ade2fd4ef41137a3e96a4bb1201df61642996e80c9f5dff7e56a75d7c6b86666c680d9e3169ea2c372e3719512787e4ae1094bc374e0a2f2c868e4043ab16b709338c53f4f3d62ad374cf06e0eacb0e172c07a2afcfec1068e5b651063361bebb5991440998685919e6612e847c105d7de9e82abfa1d3f28382f3d8696ba5a7ae38ca5e12e59d2512f7fdda0cc532c9b50691f2d4d075d04c91e05bae8de34ed4f11d454e2e1a883279f04b2b92941a1d0d8cf28c7d4de6aac8de693a980e779f63cec424d15bd405db89aebc5fc7aba237dfa2a0226f7e97e6c653f0f42645faf1c5059b5b607d7fa736c63ff8d8d138c1b250f3c740b890af9e1b48385873e8e99e0734ab6f06ac69edc331d6c3b81f57482fb8110121549c281df2d6eee70ec7edeb7b7233eebe0cb1c9fa1792b159684b2a6707029a8f5d2041bbaf2b884db61de042aa4dd6aab651e2136f6c5ba7b4485921872bb96c1c730cc1b2c21f31c49b8bf891e5ea3f2187b57e0fee21293488b36a28b792434861d09eff3e50eb4368ff84c04ca0f7349c4618bef99d39ee6faa3cd090d16bb89f1fa1475d5d737b9099b5109e98e44347d11a0d549da3d52ad26a1db98240637821a9af042fdbe7234ee1975474d3f0ba10c36f9728761bf95d1b94b1e677d3598d557e207ba2f0b940d53e6df056ebeaf708b2d9a1b8ba5390a6a8f32003cb8c19e02671499555a1644260234d9aab240fe78ddc8740cf9228cb1e8d60809f103109f8afabccba0ce53aa429371d12d3ad331891e6f70313f02013561f7140fade62c132cc5a1c4889f19896b68904cccb65779d214fc73482f41dc55de2c992d1386c6f7f5ef2d728c5e2d2a38c659c4393dfff4139d345a003096252ca4ac4b3355d303711a302ed280782c777fa54a398cbcd5749ad5552fe61615bce887170d7138204b743fbaf8fb0fc6a46abc3ab0bcf395e75e2ce72553a8d524423b5c9bb8087d81d5342b0fb21291d1faabcb0d8ec700c5716f16632eb61138c67e55bca9d2fabe9985d9947b3547ecaef7a1b91fad416bac4383a5188da16b1f71dd57c5d2e9cd0f82225b764ca8f3c5916e6c5c661b05cc4d4a2f58817b0b254f8fb48d2d430f774bc54004867d6658a624a8ecfeb7c733860bf8be8fee524092d760ab8534dd0b20bfe3122e2fd3b2c520e58374287186015341e9057a006e7b2e2f392dab282c0d6909989ca2e7203efd8ccb0a6ab9e12ca7ff5ac07dd578cbd5584942df565a95831774b453e1925c2232b71cd34b3da67ead6d20cf725a42d5402c28535844a50ca629b36a9f9f3745f88c858bbe52e9eee26272140bc55c5c3785563c7bb5e36bc74bc646867e757ba63fbd7d8d502adca5c62b93b2f62527b7737618802d6fe56aef02aacbb2214d5b3eaafffc565b901b927829644231e11ff722f977facd425be978a03a8e13778f49f92e7e6f05d56e6ec36a042023ad212f6ac09aaa13862d1ed180789a203a20355ace492ac1c54fb87319f8eeba24802d66577407751e0b7b93a500e105d9a4410f07c314386fa5a153e027dc05811f4d8a140a04e15e82954a121fce059111c91b58533162a068ddec9c456cd9de0b5012a093e726278c2bcf0b62dcb76ec3b08c864cc10027c7a950a5343600f49ac708ea9cf2d87b6a1364719d5eabe6e5cba7f80c84401c20cfee41a6bf9f9933ec414e7a81b28bc6834c9eb4659b1a6069d6983ac93af64f027b709808e9f4d152f47c4bc86922e66a52c600a45df26cdd605f8a598f7026eec5a8bdf0080ec678adfebb7aa7ea15a06c60562cc17e0b844a84bd45699e69e36230750caf3ad0f361fa7c24dd0086f3f2c9353df612f28de3f69464b4f055c63cd782c95710e1b92a3afc1b9c638f30690dcc59c58fed1809613e204b2b6e48d2dfae339099558eb6b4563bf45096e3f3591e0d626926f4e3c414d2c5d7155e89300d4c694319fa1860da7e8fa545a3557cafa758ccee0ed1e55361ce7800c6e0a775a5d02b674e061b1f3d225be998fc51730db1246363807c7dc0140c39951e154c4dcdb655a7b64677208a494cde0e49a28ce0f0aa6553ff8459d6b6329c612e06a5107d1f39cfafe38b9b4ccc9fc733b" }, { "category": "float_grammar", "entrypoint": "bundle", "expected_failure": "whitespace_float", "id": "bundle-whitespace-sensor-float", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a2c766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfb04392690f48e647e3112886ad131a1b763701a6df4bc1aebcf488240d173fe32e544b8a4f8d173eaf21cb52596a533176334a10f20f7b3183304248779b848c381f65ad83b8768f72a1ccb9f8231c15eb575464f1e8f4ff1a2d3ac4ee551b166aa13c4cb9a97e2e599b0d06dbde2fdae483107b5efa7c998fd9f8d15d75d5ed92416c14d7523f351650eb6b75f245753ef8ea21ec6ab288884f48f58906e3b875c16c52ebbd205738f3a0f55939b46b55c022ec9e12378bb742e0687b17ff4cdc1fb806a4d00aeff0cbf09d1d39371296ab82c329fec5ff89cd5bad33cd4967c4b6b9861b5f2682e40d1fc4db5cf16ceddec1cea67c75c033612f71f6fbaba9a91f8184ee41aa5db6bc7b270dbc08c24e2de0da4441ecb91a458b277eb4c892d00956a0c6e6d09b063ffe9de1f5611943f0810e9ec3c9f52c7910331f3dfc0e5ac2df7365e7c589e9f29a41db5d07249dcb8e3ea1dd84d5b9bdaf711b86e40852ac6eaf17a70ec459336fd624f1aa20da49b0feec0b5602301d443e19084bf62d9aba6b532d787cb864a033f067efaf2fc6c5bc635109249ffb9e0746b7614e21929194f4dbc02a5a11b2f790edbfe26a477c834def349f9cd5c3ee13acb4a764a1f74f9665e14a32a609038da6630718fbe94e7204ecd35760404a25bc59ac536d5996a08b23dd3e607a08f5897c4188eb3cded7a43890e8e54473559433d0c1298a7a4df31a61386b081072f9c63a12c9d5b8bf0d97b546f8e82e30382753969218df98787222da9bc91c324fa80ef81e6a2eca466492927468ff7f74efe165cb9ed84c183af185bfdcec51427fdc5b0ce38bdc490472d49d0312bd2cbaca0abe66d50564dde4145f6d2f495926650d52616f58bc24c36f02348b83dc1fe78b3bc879d6501ab29cf52a443cb2b2579ce574d2eca49b2dfbf4e33bcc6fee7a1225412b653973727013476e7af4b0f58b611a4a0a8f3d7da86c67386e0fe945392714e051c3ba3429de84547e511e1460d6bf7b7d7c71077782dead772d17342c622482df7f38317f2897b74a48aa7195d3029d8e6b85d6771f77bd673e4862574fff8fc0ce477661be619293f3fee27dc235e97d795dc5e4a7efa654c05746b76f10c974529191ca5578f3015bbbcddfded745c10c90ddaeef3a4e7b3e28f5910aa6854147c1cf94526e00c7d5467e02cc436772b583952cd56106fdf036613b761e45102c9d43fa9b6b75544f96b579b3d5becb5c44a3123b0cb778ee726baa4b917c5bfc088120b48d6a4aeee921a7ca0fdfed820b31025b8dba8bcdcd668ac9d077cb2b426b648813280aec920b3c3d357ab343a47d5216228d744ea86524ca89553a18ed5d6c75f60f1d8ba78e3633bd06d24ce2e0ca1b49452fa67dd56ade7d220e03dfcfd7f9d0e44371636795e7c6e494b5ff41cefaba3d4e7155c54227c267d5a6a6f9594ace09644cad6e65db7aff9062de53d7b1abae80cb6f58e66be6ba17cd68c18f778a69c61a56e0e52aae4aeb02c7ded0d6a038326fe2a36e8ed20eebd09ae8dc0eca988d0e6a60335829440a0f8d670022752dda7f4c6ee129fe11ddeeb30779706808264a656538f4aceb28f8b466e23f1006323c09170ce362580506020216caa90e6547b634b2d3de162c3559d4babeb6379dc05dfa2502737f1d9e86cd343e7661d918927a773c10d58c795126d13b430e303378ca69d33baeeebf4a9d4c5d000e66894aa22029f607563a193bd2ed5d9527da40c16d9e355b08a371710d37967c230edea56f4a1c50ed5305fa5796fd6c329563f6fab3b9366e10749c0b969c1b93f01422aba215ffc30a46197f0263d02fe0e01bf9ec63b59bb21ddfddd70f4a1ca513c9d09df5710c222670566ecb1bc4d7454760107621a22a20e59465d11ee3ffa83fd097952c7593fef3b8552914d708eb454f5cf0e56fc130b6016992a8b0e78183ff1fa940b7052c7b6603c637cd8dd3aff4553bb99fa79a087380cb19cdeb38d9ee7996be4ef193099c4b2e97b6db2a8c6c4d5cc60101d6d49e0ae4694a498c4b31352ae485cd9efca92d2df543f5d6d73df138b26fcc03b1c386f608cf3efdc504aa2079f897ca27584cf90aa1c155bf5eac90897341a07d6cdef75bc2a7f58fe8d18cf86470fe469aaf29b2b4e4b22b411592c9fe6d60a401c9e4783f1df53864ce5eb6c5bea01a664bff8ec27b9af3112d4e8bd707986b6310fdecef08da97ab022254c9f2a48ada9c47eaeb8802cd1bd3a1d8a49b609a1c4898e8d04bd259d38f8de3f2c78be5010fab397892f3362c3275cfd3e760eb05f9090af703e0245ac8ee3055742a20c70fb9609079eb3fb22e5d7edf56fdb6fe968aace07fb552e5702c4844d9f8a906cb715546c8b10640f04a267e96e3b2d1814a690dd4861f0f399135390f46a856a6a88bbeab79b78d8738ca5ce3a6d5a689ee74bf47aa67843c556f22ec71b740add3fa4ff0167efeed1ec8456cb5fe364f663ca33f4cf1d9a8e8b6373b925f5d6c34b1cdc060aaea3a92d05ce8b81b16aaee5440899753c1073127bdcc8c68b70324a41787a5b04cb6f836c966147324645dd630302a9cf55204764fbf8dd6b8c72ce06277e1c6f4cffebc68fde17c63c664ebdf03f5fcee5a1fb55a2861341158fe81fd80da85ddce7f4f3333daa579bb20f35b70e3dea8d8ee88cd49c36c2a81cda80f91e5f15a1efcc781816e6456015038594775ea4bd6d741651727bafe903696375b23dd237f9a1fd633cb4117947362916d96e741f440de9fbdf154c01a625659fc95f1de335624363436a2dd6d52eddf7fa920e58457cd7c270432c201439fa23983d6b5d349675b1959bf0012f446c3aed153a3433d9eb31547b4edec3f87c47411363fc413c0592c8afe446250e6e474a0d215a93482a6a686f98ad00ff4b0e982e0199e3435cf3898167723f6c12d10df5cee2837005ef4c8621cde512192b19ecb0aaf9321132b9808942b4501b4bdba19b5e8f4a05d9b0613b7153ce44ee257389c78450ac2085b2e60a3288b26b6dd4407b89ccee1e5b4e8d00d356bf10540d9b616860134496aa6805598fa5a86390b4c141adb1467766a98f076b6b50e56fd9d388c0294155292ce146cfbce7e41fa9a53aa9e5a39a192136a28725b8ba74625808f2c4d0f2d2536654f7759d9b357e8772e8e7f3341c715a9e6ba302c88c7f123cd4a8d61763397b1181c7b01e5b0da9906dc162d025b3c824d54aeb890acf168f734da196da32eff00d8913c43443c786767ddd0b7222259141aa869cfb8638f29e7ff80666a8ecd26d5ed7adc462af9736d5ac093a6d36a45b005dca99d52cde2e8a37a9e44fb5f7d0759c4924965778076ddff4f8b2658ee43beae1e75f571e916e4e378582cb4012e7e1fdc2c2a1459a0551f19a4c268d996baa0fe002022ca03ef32b3b8d2a98bfe03eb5ec57aa558a251d9c65771f0deea46b6fc60ff87868652ec0a468cb1244287297fcb064a93208f4946842ba1f71fe27e1faed4f270e1b70950d33ed67505fec01bb4192ba04960b2b4c476a138a6401fe783411dfd5834c69e3e328ae01a6b4d8145fa08590411c9addf0dda44596989d1823fb44d36aa70f08cbf9a1bfad1c7a9b1a5e08fe49c9ea60315830a7483e884361ed3615420a0969e63e352620946d25cf58eb2ccd6c64733411dfac5eedde59cc35e07b114393d87b446ac04659b654f010500d6f16ee0c863223e383691b90026925b259c38955039344e7125096d41750118758cba712ebb6faa06e5663542f47cc431d082b90473020419debc8f3ab0e506cd1da623c5a5ed05541f376162b067c7e5f40fe914661c21031093a828ee02e8d4d1f909f2b55832205a663a95fbca815ca80cdc232bd74360753c4760aeaaaf99ffb44594ae64c79396e716a1d091dd9ab3f6c1dfff0a5e502271bd6baf2934925815d041027db80987254030abe4194cc1ba1165b8648792cc4070442cfcbe291874b4aac29e2e4a05bf2c3340594d56d02ca8a606293b3e0ff2632d2f6a32ff16337d0d1ab149353087911a97a695919f81048b28215f05670597ebedf579873440d8f41fc887e0e4b2abab35ba4e08ce97701156628d922a10aa005c6530b643c695e8d7b1d650e1e2f25e5b565f27f77f12e7f93e8a301ab6a7003e5900e8ec3b0ffc5e6c2b40af9ceb81c73e7cd68264da4affd65485b5da7b339d5f41063d729ac6c230d15f95ef7bf12ad23c63425e5331fb88e579d04d63f170bbb47003c7d6ee8200c0e1f40e4b8959da994635319ad0da66271f7a25cbdafd50daf0e2e6b0f36e468cf454de1294e8ed7219e0dac101cf01e999375204401eaf44e7b8c1428db11c65e5c227d06c48e5b12614b4efc23f47ca00381d6710e1e6438121b0914c14de248618ccaf5308b6a77e6dad1270a2bf7993c024525eae3004401f60" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a2c766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfb04392690f48e647e3112886ad131a1b763701a6df4bc1aebcf488240d173fe32e544b8a4f8d173eaf21cb52596a533176334a10f20f7b3183304248779b848c381f65ad83b8768f72a1ccb9f8231c15eb575464f1e8f4ff1a2d3ac4ee551b166aa13c4cb9a97e2e599b0d06dbde2fdae483107b5efa7c998fd9f8d15d75d5ed92416c14d7523f351650eb6b75f245753ef8ea21ec6ab288884f48f58906e3b875c16c52ebbd205738f3a0f55939b46b55c022ec9e12378bb742e0687b17ff4cdc1fb806a4d00aeff0cbf09d1d39371296ab82c329fec5ff89cd5bad33cd4967c4b6b9861b5f2682e40d1fc4db5cf16ceddec1cea67c75c033612f71f6fbaba9a91f8184ee41aa5db6bc7b270dbc08c24e2de0da4441ecb91a458b277eb4c892d00956a0c6e6d09b063ffe9de1f5611943f0810e9ec3c9f52c7910331f3dfc0e5ac2df7365e7c589e9f29a41db5d07249dcb8e3ea1dd84d5b9bdaf711b86e40852ac6eaf17a70ec459336fd624f1aa20da49b0feec0b5602301d443e19084bf62d9aba6b532d787cb864a033f067efaf2fc6c5bc635109249ffb9e0746b7614e21929194f4dbc02a5a11b2f790edbfe26a477c834def349f9cd5c3ee13acb4a764a1f74f9665e14a32a609038da6630718fbe94e7204ecd35760404a25bc59ac536d5996a08b23dd3e607a08f5897c4188eb3cded7a43890e8e54473559433d0c1298a7a4df31a61386b081072f9c63a12c9d5b8bf0d97b546f8e82e30382753969218df98787222da9bc91c324fa80ef81e6a2eca466492927468ff7f74efe165cb9ed84c183af185bfdcec51427fdc5b0ce38bdc490472d49d0312bd2cbaca0abe66d50564dde4145f6d2f495926650d52616f58bc24c36f02348b83dc1fe78b3bc879d6501ab29cf52a443cb2b2579ce574d2eca49b2dfbf4e33bcc6fee7a1225412b653973727013476e7af4b0f58b611a4a0a8f3d7da86c67386e0fe945392714e051c3ba3429de84547e511e1460d6bf7b7d7c71077782dead772d17342c622482df7f38317f2897b74a48aa7195d3029d8e6b85d6771f77bd673e4862574fff8fc0ce477661be619293f3fee27dc235e97d795dc5e4a7efa654c05746b76f10c974529191ca5578f3015bbbcddfded745c10c90ddaeef3a4e7b3e28f5910aa6854147c1cf94526e00c7d5467e02cc436772b583952cd56106fdf036613b761e45102c9d43fa9b6b75544f96b579b3d5becb5c44a3123b0cb778ee726baa4b917c5bfc088120b48d6a4aeee921a7ca0fdfed820b31025b8dba8bcdcd668ac9d077cb2b426b648813280aec920b3c3d357ab343a47d5216228d744ea86524ca89553a18ed5d6c75f60f1d8ba78e3633bd06d24ce2e0ca1b49452fa67dd56ade7d220e03dfcfd7f9d0e44371636795e7c6e494b5ff41cefaba3d4e7155c54227c267d5a6a6f9594ace09644cad6e65db7aff9062de53d7b1abae80cb6f58e66be6ba17cd68c18f778a69c61a56e0e52aae4aeb02c7ded0d6a038326fe2a36e8ed20eebd09ae8dc0eca988d0e6a60335829440a0f8d670022752dda7f4c6ee129fe11ddeeb30779706808264a656538f4aceb28f8b466e23f1006323c09170ce362580506020216caa90e6547b634b2d3de162c3559d4babeb6379dc05dfa2502737f1d9e86cd343e7661d918927a773c10d58c795126d13b430e303378ca69d33baeeebf4a9d4c5d000e66894aa22029f607563a193bd2ed5d9527da40c16d9e355b08a371710d37967c230edea56f4a1c50ed5305fa5796fd6c329563f6fab3b9366e10749c0b969c1b93f01422aba215ffc30a46197f0263d02fe0e01bf9ec63b59bb21ddfddd70f4a1ca513c9d09df5710c222670566ecb1bc4d7454760107621a22a20e59465d11ee3ffa83fd097952c7593fef3b8552914d708eb454f5cf0e56fc130b6016992a8b0e78183ff1fa940b7052c7b6603c637cd8dd3aff4553bb99fa79a087380cb19cdeb38d9ee7996be4ef193099c4b2e97b6db2a8c6c4d5cc60101d6d49e0ae4694a498c4b31352ae485cd9efca92d2df543f5d6d73df138b26fcc03b1c386f608cf3efdc504aa2079f897ca27584cf90aa1c155bf5eac90897341a07d6cdef75bc2a7f58fe8d18cf86470fe469aaf29b2b4e4b22b411592c9fe6d60a401c9e4783f1df53864ce5eb6c5bea01a664bff8ec27b9af3112d4e8bd707986b6310fdecef08da97ab022254c9f2a48ada9c47eaeb8802cd1bd3a1d8a49b609a1c4898e8d04bd259d38f8de3f2c78be5010fab397892f3362c3275cfd3e760eb05f9090af703e0245ac8ee3055742a20c70fb9609079eb3fb22e5d7edf56fdb6fe968aace07fb552e5702c4844d9f8a906cb715546c8b10640f04a267e96e3b2d1814a690dd4861f0f399135390f46a856a6a88bbeab79b78d8738ca5ce3a6d5a689ee74bf47aa67843c556f22ec71b740add3fa4ff0167efeed1ec8456cb5fe364f663ca33f4cf1d9a8e8b6373b925f5d6c34b1cdc060aaea3a92d05ce8b81b16aaee5440899753c1073127bdcc8c68b70324a41787a5b04cb6f836c966147324645dd630302a9cf55204764fbf8dd6b8c72ce06277e1c6f4cffebc68fde17c63c664ebdf03f5fcee5a1fb55a2861341158fe81fd80da85ddce7f4f3333daa579bb20f35b70e3dea8d8ee88cd49c36c2a81cda80f91e5f15a1efcc781816e6456015038594775ea4bd6d741651727bafe903696375b23dd237f9a1fd633cb4117947362916d96e741f440de9fbdf154c01a625659fc95f1de335624363436a2dd6d52eddf7fa920e58457cd7c270432c201439fa23983d6b5d349675b1959bf0012f446c3aed153a3433d9eb31547b4edec3f87c47411363fc413c0592c8afe446250e6e474a0d215a93482a6a686f98ad00ff4b0e982e0199c202ed2349273792da0c73e1fc246f335310143a5d0033c8715358fb3d1d312c4973c0c369e19954d4b0fe4aabe0ba9e9b8b845fa0a12bf1a32f649f142259a795406d806522175a22f9c3cbfce531cb793c2e083bae686103163b61d0180f61b861a1f657cb0804f9efb6bb0344d540c5ad6147c2f318bf661ada85e58f3d23a8d038f0e5395c42847e6ca767bcb955cab820b21b981126a3a7c55deb14b25b3a4314907170f377273455ddcb352f22636966c3241d54af8e8ec2b6190df902fcc42826f6e3a84a60313740de4d685c8518552391f59739a5535a2b998a3ff70fe27cd0262ac22fe66d69f6c54402e64662bc5c8d62e23440211b262c2af709025f5f790797f85c627b3e3748a5b2ef134751b99d3abd3716e9c13caa98754ccd3deae3c865cbb527d1c009f814f726c9d26d3f100892759f558bfa91449de6ced1eded277572da85036660fde2c381a57f543121997e975dd9e808aff171c10ce06ef37a9e9cab19bfc03f9019474f343ef49c1a75b70edc8e15cbdf375f48d8b815b895517dbe860569a2b30d31e2b9f21925f4d9e20aee214e87318a7b1fc7eb1a00d42cf3f9a6d1d9fcc1aa90f20ba427eb0b4c263a529a72611e9d55c19f51f2c87c77e3f8afb31475b9745e00e583527c4ebc7159a495972d08a9139a3562bfa7efec3bd9b1ae1cac6aebb99cb92e094a4970c1a8216259bf095351ec16f5a75b69b9e50c84f66017cf85de2b280c8d3c642291005e2d4edddf7c3925051aa592125e4b646aa1265976959100405a4ae3fb7912736233a7989a16324925d339c34994628354b032b073d5671130474c0a2694db96fac10e56a3a40e37fce34de8da50f7c131619c8bde934beb31bc915e13b84fcad0854041c4d74a667dde3f53edf19200439431d93b371b511eec3cae459fcbb1730215b7d2194fcc0bd77b508d4191ad84c61696d5f78b8aeaf8bf1ba1082a364f4b88be31e9bfa90caa681f2c4dffa100f483a67b96bbd76659c0e0e9d591a0daf0d82383b6ffab34008d5a7132aa07cf785c8456d3f22f2ee3e1c66a8ab8e86363814ac2a2e4a535447df36a3aa013c28381ef30523213c2ffb1e74654c43f144352bacbd0c81a68f979eb032866e3947456a058cb2b6e67f902f5d88fa118785e1e5a9b0aa32b07223d393782b676d82933641b2184b6030a44dc8c0fedab1e57bfce6fa647157481bc57b17e7fc24db2802a1a2002c0651e6ba2042e4461c2c5baf9bec88a2612df08c20dd55fe9b0fc382eb6a09bc6369517638b7d0c632cb54d9b63c8155a159086d37350bc2bbc94ef77b58e3589cb84c39e3abafc918c5e5e20e4bd04a88c619714cc597cf7a3c1d750e8084fd1a99e0f9e0a7bb6850c2f25edf55d9b8e3755aeddbc00bca079b9b6f1d4d3d00bc45ecbe801ddda0162ef783679b7e47fd820f05aff1ce0f5c9c5945472c50afff5cc26ff4cc0e49cc3cd95580bf4e45e5e10c51852f1ef92477bbcc4e052b3c0e40410d1e71465435715c4300681d471642d1e519040dae56b36cbcf9f507fce17c24638beadb1ad88438cab9be532baecc1b80f747b5c374b694cb176948fb4f05944ec5d384433e0ccd3d11a0008b36cff2fd82d06171a08440b2a2c42710049302a01ed2ed4791668d272fe6d1f1c24110b5743bcfb8e7615aff1ed58aa4e6380ec9681744e060424c7c9cf8fec9ede66fa9996f1733625e56669afd0c3e3d9c9b6a64e0f84d95842ac45b" }, { "category": "float_grammar", "entrypoint": "bundle", "expected_failure": "hex_float", "id": "bundle-hex-sensor-float", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a2d766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfde4b888df257f955b76a55a127cbf23892f063883b8f8b9aa084ec77aca31802c70c2fa559e187e89fed50ffb10249684c8fa5fe3bde493ebed6b646c06332d07059eae32a693bc9788517f00c3cf53f4764ab8f421ad0ccdc8f127165ed52fcb31ef0db852bf968fd71a68c6f961532a28884450935c91ccfe4f4f30179625a2ad7a43cfc33d2b8efc47e21d1c5eeb9a15b2d2418a343cdfa32e5fbd2c2ec1c87d021392e6a2c7070fc1601b1775ae5d137d7b9ffb97eb0027c10d6ee6427d6dbcb96ebed482760446f028ae66a158ac2613a34365d450461b90d4e76a154938b2c6287109ce55a4d0c875a123a00f4614767a4031f2186898bbca4117d03ad9543890e37ca46e7ad3bad525945648f21cba3bd957c112ef78cbd04110d65b5edabe15450bd1bc0e242fc83251272e9dbddcc25962f577459ec85c82107a73531288714ec2a37ec8d5eafb5378e81730dcd864b03691ec6e6d70119527a9c22d6175986e0dc2b8b8a267054b30d16e5e1a47259d0ec38b7b6e58993876c18487c79589f36b976c48a975152b8d3490092d978585ff5c088f82bed30aa1bbaef2be0848bd875174721b557894be427cde1536a59c62fc3fe25df2531b62c62b521d483e3859087191995f719cb62c6245c806323da24605c71a0dcc027b81983f4f060eecef78a21c03861914327bb2cfb2e70413db1d6a43e48aa495e19d27d801a6e9127d124b0c0e4ded20b1c07a37f1c8876c496cca54b19c48d9648276a2bd29f8770f35d4c4a5be023bfcc18e182e9c1c2e9e04ae25df5f3ac0e204369cd45fe8fb3818eecdb4b2e5234b71fe55791d08f84e834c2518db5f35b2bd6c942d80aa77f324976240f54dc180ed86c167d8341d9579e60207584e37bd5691d345e91f827fe3be238402112044c9d1b73ddf1043e723693f91f3be4fc04684af61f96e177da6173bb89717d8e8c3b26bd7202fb037270032fdfa04131c39dec8d89648e51d8084ed8b6ed3991437af8e305e10e6b6f07ae2be586a698f9b2fbcc83401c406f9d726a34651fe3c27341432372b5f0cae3b285f7d17c4af19dc7c320f47151cb7c22ea9f9d09c0ade0eb2466894c5927b9628fb8365cd5528c792fcbd23ee796e8a5abbcceaeb1b3818b918d243761c7ec1a23870d74d36a16f1387b6fd66bedf69fabbdc512068e3179fdc7c54588d1aa22328121542cd95998b37ec0ddcdd28188004d853a60586265f7b6f3f26033f869d8218830248e22ee2021ba0cc2ac00e15539ab210073919af8991916761cfbb7876d142e734ec6e4810860bc358615f38c75bd9335762e41bd3f4e10a9d30d7bcd4d6ed5227865de39cbf49f72cec34709f069f964130658a0467b2b8f9ab4ddfaac0b9cc9adf88fbb435397c3fbe5075fe0a4e3de93fd84a988c4679e1ead841b82fae972278adfc62fcff0534697414c9e472d6cab3eaeb72464b10929e717ebf1d51c8473d8c5e6c02d87101b6ea3f7412f45d516159b69d76d973635ee56b65e80ef61e83a3491c500f68bde6de17ade3836aa1d42880f0a69b566520f6cb126167759d76091e25f71f0ea5cc60857fa7a4e7fbeae707b0cd208b66e4c442e5a2fecd9b39393dd16d72d640166b355fa75fa812b9df5b2c47db79b06d46d9690a0db0eae07293d446a4dedac5f78a6607ec2eed04c61a726d0819ca1a176e1557274ca682814e648055448ddcbe73f5a5088f98b991bd5fb3955d78cfd111b136a1db2c3caa4c292049116af56c18570cd9ac2a0a33d8bd3474b0726a97cd7d57def3f1507e4c63af826e93ff3742b4705f84409f71a890ac14a88236e71045015e1102e7fdc3355cae8e6a31029dfe2537fed3ae79d07f507161b0931b006c9205d3df5ccb7f9c6f4b142c003d3a3d2df5896a4397fd8b45b37fc7ba10cb0ef7254206dc9fb6aa6f7dd740b0538af377b66de6b6d850a5a24499eb3a860d3ed9ca91f48e4c6915bdd11862303d813c9d9b8d0535f5a563e9ffcfe93beee59957c965cf40b640996ecc4f6af7d381f6bda173d611a0f3fe42f4ad6f6e1ba0e03a99a042a8b51a87ccdf29e7d0e20802a5a69ee6d8ac3de9a3db484682da23361dfd0c32567a3c2fcc43124f1668b62c9ca6e4f4861a47a2a022b2aa74c122c52bc4bdacb78945d5c4b2ee3fad1b2b0462f176731fafcb5cbbd2832f7924f6a6aabebe432447c1b43ce38633d6551735f798b05b91bed6f79e1f43db668df3a25575318cbe259d0ce554668a50c3ca07890ea973825ed1b555655fffd3001c3981a6c06553947007fb83b6631a60d1356a8ad6a2af39fe5c14f0a932935440aac910d7b9ad3bc08a3bc76e7a3a523ea1e97e1b4518f7831f0dbc016e91562ab2279450c25b85229fa75638576c258db026a2a617599fa9558e54edeaa127052dbbb569f70bbae36170ea9ad35847821132a536ae1bc27ba858dd56f6fef5e92874c7fecb56dc235e65b92712786dac0600848b1e9c1c29149ac01a7dc342d31b8978af7d7623d957d4cb5b619270b235c9d62547bcac60daedd5cfcd7a43646dfafcc137db80db47321d68fa18e1326674d86224d11bec2f4fa204427a53f93a5c9b6b6bbfac4da9a0ecfedc6bd2616b45da10d469c43deafdaeb05a2327e71c3e135f76b6bb12bb7fa6316e40968d69d801f0634f26d9f4195de627aa86c2404609b3a80b10ce874ec758c951a11629ba32c582400401d3cac113e219eaea4f083fd73d1797546364dcc321e189b6668e12b73a8ad7e0bede2c646efa5a645db4d59c7bc11c75fc3d53690df84b1d7b565296c5128541205128bb8f3e0132438f4f8640bdd6c9f48cd40863fe10a051721b468e280b7acce9d02545c3c444a2880d8b391b76c98d2620d14101fb0fbf0123dd8af102640858f1b365d0fd59144d2c32abe6b866c474cd8159959e906ff81499e1bc64b387c07ad08fc248a2dcf6f5e783911e7cbdd54ae21d18564d3dc935c36179db6c48927b79d7a6dff09e71e60aa9389e50c2df13ec0b7e39b261ce7d2dd55c4f8f2adae54f3c5c43c50753b6ae0ca7c89e31edf579aa567065c7f0fbc1ac269c0f2c18e19b8d1426b27f5936ec19d05212ff239633af2572302d56f818976d015d2fa539facc59d48d847e866aec097b24806d66b5a1e1b1e56c77c03528be1748ab45f4548037ce5e454ea95eb3c5b6243c85ed251073f8554e41e36f291ce516e7b2fa161bf66f2e93690945b1fb6273327b023f9095a0493b8eac781ee6fa99a3f89e0eb338a0d7ea817ca6f9fba9175df55e7215b45349d8c871b6744678f6d4b1fce10e1b597d6dff679d5b19a0cc164525ebec993d0094300628ed96c62b331c11b2ed1313eece99b4ce8747a89a2c0a3904115d47beab6eb5edb7c04c47f73061b2441728cec48f2c7d6da008090ea0aeac92db9411bc3ae98e6622a3fb9b13174cc805733ffd7cf1c2faef457a9dcb19944718f1bd1fc15b4f6b7fdf675d4bbabf776d87b075c79f04b0c4954b3e3155cabd92db31c546f8aa95989b62b57aaad85d6019a173c678184117691a7dfe97ffd4287ea0662cd4041af5a38d64740dbade5a22f3769316c2d7a30c3cc259c13183a55fcd86f96dc6df2f21952ea9f4212780778d0719a46ab042d74ef94aa2348a29ace8d9c049a44776e3905a7fdd73fb5a621467399dc105bef303e613798ac7ef6b77d1a127ed7fb3afdb74e8475d3cd0041b44bebaf4f34e56bd43dc7367f0aa577ddde756576734a9f56b578cd72d4210d6f80e161444eecf4b97cb3e4ff8c263d83a87bfc661dfa8595da66e7032e2d7778b740fee86790f21fe9557df0a19c2bc84ed7d3c9a5ed12b5dcd88bf734d7a6e1e08e355fc13c48b19d4df39264eb290830e22c688244729cb8c3f6c3de4f444664b5e311f023020989af326cd8b717848e4b2f28398d8bf48bddc0f3123d2d16d61e167917af0748c3885c6d28a382ead8cba02f2e576222f528cc11c30ff9c98964d3bec344e1cb833a9ab8ee830ae174726baf4adbc55b5b5620c0c7c75b0efaefd8c30cacb9a8dea8bd3435d8cc8ef02b9d90af04ea84abce55b0c22c2188c43f4c4a4cf4df7b6ec8f592f7debc9bcbad32271baf3754e18335cbd0ff2dee6513f150fcededd092ccc3e08eeeba6556158620e36b71267f7edf283210663b4208bb40d425656e811cdd3414bd169d04a9f8d562934d38d366eac2b30266ff5731a3cd96ead5fbeb9bd68e6f8091503635a88d60e5b18cd5a0613dc0e2965ad9e8d3a4f17079c54157a52d7940eeecc61140503b3a0a57a9bb08047ab68024849bf88d2b86031c52336131662ff2e28c26fbf2ded3937e497c3d9fde6ddc8d458efbeb185f89f22f437c54dd3cc648da214046060b30159d6e6808fa379ed92f21e45d8f4167d3e86f35fcc0941d2088fbaded9958c42afadc5736b7d8c2ad" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a2d766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfde4b888df257f955b76a55a127cbf23892f063883b8f8b9aa084ec77aca31802c70c2fa559e187e89fed50ffb10249684c8fa5fe3bde493ebed6b646c06332d07059eae32a693bc9788517f00c3cf53f4764ab8f421ad0ccdc8f127165ed52fcb31ef0db852bf968fd71a68c6f961532a28884450935c91ccfe4f4f30179625a2ad7a43cfc33d2b8efc47e21d1c5eeb9a15b2d2418a343cdfa32e5fbd2c2ec1c87d021392e6a2c7070fc1601b1775ae5d137d7b9ffb97eb0027c10d6ee6427d6dbcb96ebed482760446f028ae66a158ac2613a34365d450461b90d4e76a154938b2c6287109ce55a4d0c875a123a00f4614767a4031f2186898bbca4117d03ad9543890e37ca46e7ad3bad525945648f21cba3bd957c112ef78cbd04110d65b5edabe15450bd1bc0e242fc83251272e9dbddcc25962f577459ec85c82107a73531288714ec2a37ec8d5eafb5378e81730dcd864b03691ec6e6d70119527a9c22d6175986e0dc2b8b8a267054b30d16e5e1a47259d0ec38b7b6e58993876c18487c79589f36b976c48a975152b8d3490092d978585ff5c088f82bed30aa1bbaef2be0848bd875174721b557894be427cde1536a59c62fc3fe25df2531b62c62b521d483e3859087191995f719cb62c6245c806323da24605c71a0dcc027b81983f4f060eecef78a21c03861914327bb2cfb2e70413db1d6a43e48aa495e19d27d801a6e9127d124b0c0e4ded20b1c07a37f1c8876c496cca54b19c48d9648276a2bd29f8770f35d4c4a5be023bfcc18e182e9c1c2e9e04ae25df5f3ac0e204369cd45fe8fb3818eecdb4b2e5234b71fe55791d08f84e834c2518db5f35b2bd6c942d80aa77f324976240f54dc180ed86c167d8341d9579e60207584e37bd5691d345e91f827fe3be238402112044c9d1b73ddf1043e723693f91f3be4fc04684af61f96e177da6173bb89717d8e8c3b26bd7202fb037270032fdfa04131c39dec8d89648e51d8084ed8b6ed3991437af8e305e10e6b6f07ae2be586a698f9b2fbcc83401c406f9d726a34651fe3c27341432372b5f0cae3b285f7d17c4af19dc7c320f47151cb7c22ea9f9d09c0ade0eb2466894c5927b9628fb8365cd5528c792fcbd23ee796e8a5abbcceaeb1b3818b918d243761c7ec1a23870d74d36a16f1387b6fd66bedf69fabbdc512068e3179fdc7c54588d1aa22328121542cd95998b37ec0ddcdd28188004d853a60586265f7b6f3f26033f869d8218830248e22ee2021ba0cc2ac00e15539ab210073919af8991916761cfbb7876d142e734ec6e4810860bc358615f38c75bd9335762e41bd3f4e10a9d30d7bcd4d6ed5227865de39cbf49f72cec34709f069f964130658a0467b2b8f9ab4ddfaac0b9cc9adf88fbb435397c3fbe5075fe0a4e3de93fd84a988c4679e1ead841b82fae972278adfc62fcff0534697414c9e472d6cab3eaeb72464b10929e717ebf1d51c8473d8c5e6c02d87101b6ea3f7412f45d516159b69d76d973635ee56b65e80ef61e83a3491c500f68bde6de17ade3836aa1d42880f0a69b566520f6cb126167759d76091e25f71f0ea5cc60857fa7a4e7fbeae707b0cd208b66e4c442e5a2fecd9b39393dd16d72d640166b355fa75fa812b9df5b2c47db79b06d46d9690a0db0eae07293d446a4dedac5f78a6607ec2eed04c61a726d0819ca1a176e1557274ca682814e648055448ddcbe73f5a5088f98b991bd5fb3955d78cfd111b136a1db2c3caa4c292049116af56c18570cd9ac2a0a33d8bd3474b0726a97cd7d57def3f1507e4c63af826e93ff3742b4705f84409f71a890ac14a88236e71045015e1102e7fdc3355cae8e6a31029dfe2537fed3ae79d07f507161b0931b006c9205d3df5ccb7f9c6f4b142c003d3a3d2df5896a4397fd8b45b37fc7ba10cb0ef7254206dc9fb6aa6f7dd740b0538af377b66de6b6d850a5a24499eb3a860d3ed9ca91f48e4c6915bdd11862303d813c9d9b8d0535f5a563e9ffcfe93beee59957c965cf40b640996ecc4f6af7d381f6bda173d611a0f3fe42f4ad6f6e1ba0e03a99a042a8b51a87ccdf29e7d0e20802a5a69ee6d8ac3de9a3db484682da23361dfd0c32567a3c2fcc43124f1668b62c9ca6e4f4861a47a2a022b2aa74c122c52bc4bdacb78945d5c4b2ee3fad1b2b0462f176731fafcb5cbbd2832f7924f6a6aabebe432447c1b43ce38633d6551735f798b05b91bed6f79e1f43db668df3a25575318cbe259d0ce554668a50c3ca07890ea973825ed1b555655fffd3001c3981a6c06553947007fb83b6631a60d1356a8ad6a2af39fe5c14f0a932935440aac910d7b9ad3bc08a3bc76e7a3a523ea1e97e1b4518f7831f0dbc016e91562ab2279450c25b85229fa75638576c258db026a2a617599fa9558e54edeaa127052dbbb569f70bbae36170ea9ad35847821132a536ae1bc27ba858dd56f6fef5e92874c7fecb56dc235e65b92712786dac0600848b1e9c1c29149ac01a7dc342d31b8978af7d7623d957d4cb5b619270b235c9d62547bcac60daedd5cfcd7a43646dfafcc137db80db47321d68fa18e1326674d86224d11bec2f4fa204427a53f93a5c9b6b6bbfac4da9a0ecfedc6bd2616b45da10d469c43deafdaeb05a2327e71c3e135f76b6bb12bb7fa6316e40968d69d801f0634f26d9f4195de627aa86c2404609b3a80b10ce874ec758c951a11629ba32c582400401d3cac113e219eaea4f083fd73d1797546364dcc321e189b6668e12b73a8ad7e0bede2c646efa5a645db4d59c7bc11c75fc3d53690df84b1d7b565296c5128541205128bb8f3e0132438f4f8640bdd6c9f48cd40863fe10a051721b468e280b7acce9d02545c3c444a2880d8b391b76c98d2620d14101fb0fbf0123dd8af102640858f1b365d0fd59144d2c32abe6b866c474cd8178d42f40af5e447904dc05837611db015fa25977cd70e7e217d05fac9924cb64ad5987bceda825d501993e6d38d35ab9c3675e768eb1f68a59d9be21f38ec38c1a6ef812815e8c7dc52d3e2fbbeb94ffccac83d367b353cfdce74cefb5f861797a57c2d706919bc00c468f1c4ccc801a0914f6b3cacc87dc78a1e3c7ffc372c38f3563819d4688bb55dcd11dac06290a3c48119c05ffb67aed299bc1d10db6b6935061a5efd560245b7d3708fb45a5f191b64f6e554f8caba320d790ed046b35d06378a5afc1725e58ac15e627a49fe30e83fb6fd2b92db4307d72b322fbf2de100491388b7e5cb808861a7ca288df2f732c61566c91bcb7289b18b7ed242f43e57573c8d9392106c450181631d02ca18a6add68f9ffb79ce9ab61ad7644858bff8a5dd4f5b182283d9773be822c70c35cc6130e2a6994de96f6188a5ca9fbb5c11dc41dbb9e45fc72d1cdc6f7106092a4f279ae148c1eccbde08babaeb1dd6fb29bc411ed9ff80fe72283fabee607a9a9b1a2be7b6c31d32b8ff4da2d3a4924e7c8812b4a344e3aff3e9eb779ba3b3967ad966115763fb4419425ebee41c39a5d77da41846738be54191d72756b7bb8ecc0a84153c6194800077f7a9d1bf62f94ac0f2473b8d4d41b47114c05140c1abe493193a2f29746d7730d8957e8f150f2148acd661d9de6cf3e90253ed957e39650370ea409549aa187c6cf785a9235aac949b9b9104a96f6a6a313f8dfcc00387a2244676838d0843fe333e7368c9a228edfa6502717cd7fd2cfdbb429264d2c8721ee51cb2eb5b29e724cc25a4347f0cb377d1d263747776389158e56fc960c82041779882634448faf4b573b1f3fc8623338cb470f3770ffa9394bc68e95533297f3faf01a7a86a90e934c5436bf0bb9a2af978da95d1bdad1fb5c781d0e432c0bdfcb0803b10c33d49aa864cf49858c0340c38d81d678d456ecda0dbe0c7de5d4a4831a3ee11c3081f0d8195186dcf8b25808b4b2a32689593e28fddd2ac43337b0d9b060e0900ab0255b8d70d3a71e796f7da84b8375e406627e853c21f9318fddb95659fa6db36f0d8852790b2f79205fb7f7e6cba59ddd45a3d582e96dac35349e2ae818301aca283f2beab342fdecdbfc626db88b744e784b0970ea3c43b3a95943142054ef5de6075c9f298cbf5a19fc397032814ae2b05f99b22ced0ed23e03005fc50cfc6f0d49af6e9e199d288615315833ab27369317a7ecd77631e30200f10a330d33e65698615b86b459b18d903b6f99839d57a09c25ccbfe9b552db57a27a7cf8ce199a2aceba9fd0aebbe39500f91be4cd296baeeb2491accdeac7ea4a998bcf4746fc5410eb67f261faa919956554c2038057ce2e5084e39a69b2293d3fe9b6580091d157e313f616cffe38d2cfef4acd1cb310001238cdf66dacc1adeeae1534dc8b26451734cee15d753c42d705d50524d4fd62e2611e574d09d7263b94f971e2a9ff87278af86e62108e0e46f77de5c345ecfb36ebb52dd4263e77894b1c0d15a6c30ee9d5f95e055c6f87e4df3e3927985098224be5e6ad88b326f5011d5894e30dccefb2d62d9842c011af7dfff7648a9813930ab496487a7e2d779b9f668f359e261e9ed72defc8f2ac073059d1e16cbdd66fed8fb68f7ee9823be91ef8ef33fbc6b202241c42e487da20aef14a4a90ef2c23b845e6d1d1a11de7de590885f6654053148b12d5142632e8c57bed517c9643e8fdb814b00aca108fbb5a65cca28" }, { "category": "float_grammar", "entrypoint": "bundle", "expected_failure": "binary_float", "id": "bundle-binary-sensor-float", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a2e766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf1ae75d52cdefccc51a046bcb4508b80f3847c5b7e94d9eb0c6886afa3058d1988fb2f5297d64ecdf7f26b526dd8b2eed1ef12e95d9872e7a357fd3996785d470c4ea779d8a192feaf3a18263575f6b6f810273b27e12727b6039e879444d9a9b09e694eb6b11d11e8245889cb59b0450892ad29ea86e0d06f3876d67d75c7ea2e8e33d69f03e6a245146f3f680a3375ca1a330729e86499fbb85cf10bbff42ffda6c62886daadb991d03f0e013719b7815adb57c7ef777095d291991533b72686d45c14aa06dc7eb5ee8438392a448aeefe8cad4ffd90d65ed41b34783fcb3e0144bce0a2d145b8a9acee498f3f43608079a4c2af6cb026660ff1fd505acd4ba41572dc2d875eca29a03bb1400c8401d13502ff08373024a6068e97a677fcdd615956e1517a44321cc5804abf2536157825d1df20269b33cb91d1667ba4aa5af1d4ffa0374b074e8ec03a023998bc995319901a4223b79b6e7a801e6ef6719fb63173f4c4ef3fe8ffa0cc1a38fb6773fd7532bc3487fc1b1d73d777dabf4d9a6f16a275d604e71cd4c25065993a5306a79857598b7b255640d3caca3412c52e675f19761ea2a388be9ef295665829d9cc3f854df03ef736cd9a6810162067e2677c7ed9f31050fa6a7aee8269afd2a22ccec0cf495f3083f3c6ff1f61cc29dfd5d2928b2c7c186aacdb283b497d141705d9b723390a3280ee9d275d1f92cd25b09c65068faff507407f770364ed597aeb21e08b8a54193aa8e97a0eb86e2f0ee39f1e5497895f68a41bcd45b687f3c360abd107b0dc9cb6623a9165310cdfec6f124899c7e5bd2b1c495be969ade1be434691ff90309f41e717f3f7b891df1dae1f215deeb306703bb2f3ea5d6aafb62ebc07419df14e25ab17d095b7350b1413ef198b26dd8df319b4b064cf951cf5ec184e32778e6075b3ec246bc761c3b344eb8be6a83a47374a8f7b8e2faec0c522feeb3486e60f8f4cc20c74a7211608d4ff10c2ae17cb13a9f9f452488e372d3a993ff2475add74afa172968bba2699a70e84b5a274225951982ab119199611c9b64eb3facaf5490a7408ff20b2a90cf1a227597fede104d2af25db767147c31db248c025fc3013da6fac8a88341c99a5c1f1141d10f98571d6683574ce8f82a5d76e1b33d1cf76602dfbd72d203e4f9159fe07779cf61fe1fe0bc6a8ae590b12710eacdb34989b22d29a8ab9049d72dd4e48a7e0c75d2bda5cb39c176bf502b7354ac7548c38ad518b82a0d3c4ac25f7f34bd1e6e8f2da8337baec23a2f942b511b3b1e754af1a0d03b3127024a3722c8414a47d6d9905326709f70326a39135817ae01fabd5c60564a3dfb8c4506134b3987e05f68a5732523d0f53f11c478819822c3f52f611d9a5ba4030dba3a13460c0ac6d90c43a9309b0988dbb6d7d58459c1ff54ce3ffa3f0eb5b58a86a41773790016bbba991bcd5bfa34f311e102ed03c87b8c8a08f9ae76afeee1a3d9e4d88bf4616eed969c96af03d18e4f28a6e78a3c194eb5489cd60be32e6a35eb568661a1c1aa104e3d10dede3dd2124735fffa73652d8a94527a39c625e0fb15d7d20ea3926a0d94527a7f9567310e55b1a90f46d740b9415445da24eea93fba1508e8142a5f48cf88fc038e68b5e3413c38befe4f551a9fa8e95eab126a3a5986cc647c8fd692c8cfa15b5ceb32e487d4332fa6bd5560bf25fda74ce57ae1e40e863411d53ecb2e383352a790a564dbc472fef099cfe49a870f827c96e8f5ac1d2b9ec8f50cbc545abbaf5286c3f414ab596e03e1679b07bf4ea54e8e36997aa5541ec5858b097c7e167f67c79845f525b513bf8143eb2ca6631cde16cf53c5a818873d01604f67a577cdb5f5ee061b6628048af3ae7f01677d4cbc7cf556297c427bb9b15cc1d90c63bfec43c753bef950fdacb7d8bd4fc93d2ba7e29054af8f86aa0083a9855c2b3e27600e27db7442d4c2610ace438c95333e829ce2f02af97826eafff5fc1ad1e6aee0dc23fead07db79d8fdd21f8fdcfcaeca675de392e94dc3fac09e4d3e40119a23aeda471cc543f6a4e8351331b914ccd850972d61782f21fc22132a1258d6a3335c1e1cadec2741d2e32cd52be36edc53c946af1256151e980c90cc9d97fccbcf5da7d29341c86e1351600e7945c2c0bc540358b1316fda8dd18f7270913b39adad4a16b510b00188436bce09cf6a5c4cb8ee3256c182633103e5336a813aa0ea59d6b5530edb9e01212ce3d79028bfa019aa91e19f21cb427603375c20f2e2e9024a14968b4c5b3d2fd311ab55003c987f26987f663b9387d9cf58c2952ad6e9d30570fe43de150255b81abe3f89e3379c03fe091269abe5e4861bbe23ff9dc19043514384080a8adfdd2139278daba25b6323e8217f21b11b09706982709f578e793af3f16b6c0da10a955771258b26e6495dcc5a988db82a7cb41db97a2b2aaac296006ea0db280022652d8707e530178ec4b359c336d8ad31116241b117c70c7a7a9a512095cd4c72131667d1cf568dbde911d70d9db577b91018fe1627cc5f8aff22f648a0cdd0db4b5c10f7a1bdd945549125c7b50a42d8d79b32c0f1f5d5dc37921ede4d7b062cb85eb6046676220cee991003a86f26bc8d72e90ac327678fe434b0e30308aa53738f2e5c8f149fd2921a31a5e14e96327a3bf5f29aae0dc5a06061276aff5a120ecf4bd5076d3948a046d5c2a7a2232db7d6f52f8a44ecd730256bc6cef453b1ef0d8ffe4529f7819c701c142ac65ebac7e75efbcaeb63c60e67be512ec40eb69c9fea7b9b7c339bf7af99cfa532178abc749a525bfb42d43a1e98a222a0da2e6e72623d8ce4705c4dd832e2736446288de215eacec143f57d1b41cfcac0678a8b13d57178f981de86439a5035ec56eea1f8ec61279abb26e9bbe69455e6393a536f4a8c014970764193bc91e789d8e9c050eadd477dc094a5fc0f8b82f3a8901faac5eda33bda3340471b9b56c10867c98eb5ad25580d60a2d8a2fe73734b69966fb57dd40315d5d94e4ec781fb18ce6c8073d90a84ea56ff67bf0eb3d91f59ab5a360b751bb9536be5de45aad019b0fb5c1faaa70b03a2289f917efc0dbb84006b6534c734ffe6e6675cfe398bc30b8e2080e52431add3b8855c8b1a10cd918bb7350a3fa8d52321f79f975bfcdcc8968197975301ad103392d81545ad8ca2392b9d5535727cc9e6a1467723252d27ad67bd91d3e3c6581b3846b1e68907f875f7b7b86bfcd12b92f6b8c20793431ffe63c39536a57829e1fa8eae8db3f2fa3bffc39594cbec40c7bb8cb4db71b597d1f38c77def9bc568fc3de5070bd41d46876a3c9bb4cceae1af2b6965df4eb828339fc5a647717e7b0e595077c04165d0b2aa56f43a4d4b25b242d6caef39efe65f86ae43c9eea46edd360bbbe1a52c0e617cf30cd93a2e2e9696a2622300e59bde212309cb1e92ff141fec1dfe0227edc491dd8f127a4b2a0e20d1dc24e63bfa089be6c2d535acc07ffab09cbfb16fab2f15b45e4b69c6fbde9af23467433157afd197c7dc49e36654b81ebd2557a0faf5edb742b2feb3ed0727a55cba8f3735332fc5c770d71b517dedb9ad0bf5fa2219f3a3686922d1ebb7646f9445eb9f25d8ce793a6db126ea1e180e20ae9b01de3e662f80e0fbe785867038ce63c16cdcc6e22ddcd9303441f699bb0803467753f04d224351344b01bfe89b9e5888882cbd809770339c2ff9e140ffaa8a0b991b840f7ca3b7b4cce5758d529a532688fb1691ae59f5f5cc6b292152507d6866d8f821a441f7df8d847437e652a76d846b2ebd0a1a897328385296afb4dd0c4b2ed3a2b33ef88d7d08ca1bcc297d64afbad91439f9b2c9f02edbcebff30339dce7758e17418077b01fc51d428e510759214ba6fc03b2087f37e01daf405f53f8c8d9bca16538ae696d7445133b5c5d18f6522cabee30f3e9f0ac5fb1c3fa6467511aab96ec7a4cb75e18aefbfdb8b0c26538ca46d5bca396465d6c841455f10b8f068609cfa68ce45938fa4fae5748de9bedfaf76f0949bd7fd39d694eda4de095aab16cace41c2f44228ffda2c66f7b7b4d0b8d2cad71dddfdb5b27cfd469a472c3a64694724f8a68a2687a06d2d7043d3884b623b0df93c4c5d971527af387db725540b4c7445d35a4c2d4f6ca68d4182dcdb2f11ca6e7023ba8aa4fc3190e08755cfbd516e8dff56fd4b46144b300eb7a9f428a2a339984410bcee6a9dadfe386879336d93a8114e0b8052cc2a5fac0dbf63e32f04a5cc33162fdf6a2a3212668737518ca89d66b76e3c7dac3f0cbc72151c374a09296073c1f7a5fb350f7e4c52d7241d2c13883860bf7152397b6824c3ae772ac2a37fc95590659ae1a6f650291539fcf9dce42458b2896a9f9c31d93108ade9c2233aef8537fc4ab14bdac0dbc4a3c1a7697b69dcd67ee9897a11183d11aa0ed8c7dea16d50ef5cc284593dd6e782" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a2e766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf1ae75d52cdefccc51a046bcb4508b80f3847c5b7e94d9eb0c6886afa3058d1988fb2f5297d64ecdf7f26b526dd8b2eed1ef12e95d9872e7a357fd3996785d470c4ea779d8a192feaf3a18263575f6b6f810273b27e12727b6039e879444d9a9b09e694eb6b11d11e8245889cb59b0450892ad29ea86e0d06f3876d67d75c7ea2e8e33d69f03e6a245146f3f680a3375ca1a330729e86499fbb85cf10bbff42ffda6c62886daadb991d03f0e013719b7815adb57c7ef777095d291991533b72686d45c14aa06dc7eb5ee8438392a448aeefe8cad4ffd90d65ed41b34783fcb3e0144bce0a2d145b8a9acee498f3f43608079a4c2af6cb026660ff1fd505acd4ba41572dc2d875eca29a03bb1400c8401d13502ff08373024a6068e97a677fcdd615956e1517a44321cc5804abf2536157825d1df20269b33cb91d1667ba4aa5af1d4ffa0374b074e8ec03a023998bc995319901a4223b79b6e7a801e6ef6719fb63173f4c4ef3fe8ffa0cc1a38fb6773fd7532bc3487fc1b1d73d777dabf4d9a6f16a275d604e71cd4c25065993a5306a79857598b7b255640d3caca3412c52e675f19761ea2a388be9ef295665829d9cc3f854df03ef736cd9a6810162067e2677c7ed9f31050fa6a7aee8269afd2a22ccec0cf495f3083f3c6ff1f61cc29dfd5d2928b2c7c186aacdb283b497d141705d9b723390a3280ee9d275d1f92cd25b09c65068faff507407f770364ed597aeb21e08b8a54193aa8e97a0eb86e2f0ee39f1e5497895f68a41bcd45b687f3c360abd107b0dc9cb6623a9165310cdfec6f124899c7e5bd2b1c495be969ade1be434691ff90309f41e717f3f7b891df1dae1f215deeb306703bb2f3ea5d6aafb62ebc07419df14e25ab17d095b7350b1413ef198b26dd8df319b4b064cf951cf5ec184e32778e6075b3ec246bc761c3b344eb8be6a83a47374a8f7b8e2faec0c522feeb3486e60f8f4cc20c74a7211608d4ff10c2ae17cb13a9f9f452488e372d3a993ff2475add74afa172968bba2699a70e84b5a274225951982ab119199611c9b64eb3facaf5490a7408ff20b2a90cf1a227597fede104d2af25db767147c31db248c025fc3013da6fac8a88341c99a5c1f1141d10f98571d6683574ce8f82a5d76e1b33d1cf76602dfbd72d203e4f9159fe07779cf61fe1fe0bc6a8ae590b12710eacdb34989b22d29a8ab9049d72dd4e48a7e0c75d2bda5cb39c176bf502b7354ac7548c38ad518b82a0d3c4ac25f7f34bd1e6e8f2da8337baec23a2f942b511b3b1e754af1a0d03b3127024a3722c8414a47d6d9905326709f70326a39135817ae01fabd5c60564a3dfb8c4506134b3987e05f68a5732523d0f53f11c478819822c3f52f611d9a5ba4030dba3a13460c0ac6d90c43a9309b0988dbb6d7d58459c1ff54ce3ffa3f0eb5b58a86a41773790016bbba991bcd5bfa34f311e102ed03c87b8c8a08f9ae76afeee1a3d9e4d88bf4616eed969c96af03d18e4f28a6e78a3c194eb5489cd60be32e6a35eb568661a1c1aa104e3d10dede3dd2124735fffa73652d8a94527a39c625e0fb15d7d20ea3926a0d94527a7f9567310e55b1a90f46d740b9415445da24eea93fba1508e8142a5f48cf88fc038e68b5e3413c38befe4f551a9fa8e95eab126a3a5986cc647c8fd692c8cfa15b5ceb32e487d4332fa6bd5560bf25fda74ce57ae1e40e863411d53ecb2e383352a790a564dbc472fef099cfe49a870f827c96e8f5ac1d2b9ec8f50cbc545abbaf5286c3f414ab596e03e1679b07bf4ea54e8e36997aa5541ec5858b097c7e167f67c79845f525b513bf8143eb2ca6631cde16cf53c5a818873d01604f67a577cdb5f5ee061b6628048af3ae7f01677d4cbc7cf556297c427bb9b15cc1d90c63bfec43c753bef950fdacb7d8bd4fc93d2ba7e29054af8f86aa0083a9855c2b3e27600e27db7442d4c2610ace438c95333e829ce2f02af97826eafff5fc1ad1e6aee0dc23fead07db79d8fdd21f8fdcfcaeca675de392e94dc3fac09e4d3e40119a23aeda471cc543f6a4e8351331b914ccd850972d61782f21fc22132a1258d6a3335c1e1cadec2741d2e32cd52be36edc53c946af1256151e980c90cc9d97fccbcf5da7d29341c86e1351600e7945c2c0bc540358b1316fda8dd18f7270913b39adad4a16b510b00188436bce09cf6a5c4cb8ee3256c182633103e5336a813aa0ea59d6b5530edb9e01212ce3d79028bfa019aa91e19f21cb427603375c20f2e2e9024a14968b4c5b3d2fd311ab55003c987f26987f663b9387d9cf58c2952ad6e9d30570fe43de150255b81abe3f89e3379c03fe091269abe5e4861bbe23ff9dc19043514384080a8adfdd2139278daba25b6323e8217f21b11b09706982709f578e793af3f16b6c0da10a955771258b26e6495dcc5a988db82a7cb41db97a2b2aaac296006ea0db280022652d8707e530178ec4b359c336d8ad31116241b117c70c7a7a9a512095cd4c72131667d1cf568dbde911d70d9db577b91018fe1627cc5f8aff22f648a0cdd0db4b5c10f7a1bdd945549125c7b50a42d8d79b32c0f1f5d5dc37921ede4d7b062cb85eb6046676220cee991003a86f26bc8d72e90ac327678fe434b0e30308aa53738f2e5c8f149fd2921a31a5e14e96327a3bf5f29aae0dc5a06061276aff5a120ecf4bd5076d3948a046d5c2a7a2232db7d6f52f8a44ecd730256bc6cef453b1ef0d8ffe4529f7819c701c142ac65ebac7e75efbcaeb63c60e67be512ec40eb69c9fea7b9b7c339bf7af99cfa532178abc749a525bfb42d43a1e98a222a0da2e6e72623d8ce4705c4dd832e2736446288de215eacec143f57d1b41cfcac0678a8b13d57178f981de86439a5035ec56eea1f8ec61279abb26e9bbe69455e6393a536f4a8c014970764193bc91e789d8ebd44bf7d14d18ce9af3fa1c849fe9b58d19abd8bcbb5afa6a0453069919d9100ccd93aab023490c06acd6f2e977615768da77ad1cd8021ddad75c49d494e61ecf7d8c69d7038bff57f870a507a0ce0456a456376d7b15ef4e6fed995deb8959bdfb473486b11d03b82e9ea048f14ba39c4066652f9e6fece0f17c41ae37858c098f219bfe2536a7ef939858832021c29e8aab641227bbd42333f991c24df1dcfbbd9c939f692bac070fa0dc1045a896f321338e5435602398e8f75d3a3b3d4c2bac6fb29fcbead54f003749bde7ef58a922aef3ac76fe9e338a97b4c30f9c4d07f770d48e39aa742887f480fe9ab7e0e63ab3db8df5c7ed5dd1b096bfd666a688dde18467c1ecb75d8ea5d05e26a045dc2f6ee290fb41ceb531dea0805deeed83b20ff11c7c6f57eab0fecc5c6f020146167aab610ca6d5b3403f2c6ba4cfb5e361f050d53391eb5abccb78abbc1a40cee842d93dcda089ffed61500c2327326bb9e7123586e0f137e143fed380b12c28c70405c0902ba5afb6e91716cd5b68b5ad8eb70972020d9543ebb60b84e30e9bbef04653efac9760a8e2a52e417d561b74ad0e786fd89f7a7d53e0e7d3486c04b555c5762b29fe3ac1731c5bc5feee775b75e41d2e4d7cb50cf5f78cc6bf45a420ae0c3bc08a355eb6765da01f4dbfe54691b79da894106fa0fa9be30de38c36fee26ac23f00b17944361b94f73f16dfc26077cbc093306f026d938aaa3570490d00d724300915a803ef8ab9f7d7d98c9dc3446f1b5ac0ff98020ff6a4b6a890bd32a89b6c2208da4a5a9a31bd516a8fb77f1ae993494dc7b7e01b2b57c1827f9383565c071efad841557e692574cf45b8eedeaeb49c3d9297297cfa2bdecae4f03e2374f7c98e9081a1a7e9bbc05cfbb79742aead21d91af5fce6ff2b6ac6dd714ffa694809754efe50d533fe117298289172c4331ab6fc7100c6a51ded29888d89c418069ceb96e46f4c37bdfffb8e721ef8bae60f3b855bdde30a3ba6542a40a4ef758abcd305f68eeaa2a0d45d710ac8b0705985217c15c1cc4458241eb6a07f648ee669825d8bfdb5e9e36987e3a7cea06cfb989cc2ee3fc7958baad05f47af1e8dd6009bb44f28e4f10070e1b7aed6b9e3fcda5bc5e5f5bf7ce61fc1542a2d7f74172af6e9882786bb762c7749efa356663337c833435c8b443fb72f78b7375a05196248d36967304b649ca74095e0e92b14ca6b6a72a292b3f93182bfd65b99a61c76958f51e64b4113425551e68ffa6ca5bc38d51f568bdf7ba78cc2103f32794085ac135400cc0b8b5a20df68d44c8a493e9cff1f2108e951381a356abb0d75f1e9d67eb26a2a7dac661fee2d4a584a154e3c7c68c3f88eb06b0f340f42cc220a64159e7666b57015742b662380a3762bc8a679bb57c82ad39cb8e551221378a3a9cdee6f4af3c9218d9329ea1819b6f7ce1321b9dc4aa601f105c3df988ae79ce5346971839b32f99437425e4f2d82895ff716755266829b8b1ed886c247195c58ce904b6eb8badc69fb76529a8eefdcc1bd21c7aa997c5bed95746e83cb70bff51fa1e7c9456440b79cbb5c4e4c001e89bd72226feea98a9aed3716136b9e2a7673388242a9b32c4235ea023a72404bc28d22b629e0bd32871aaea443e98de0b988e7caa75be5cb1b4b2f8af5e98cc2ddac73f5c8bb8a2ea5979af7857c41e239f563c754bff9c8ff562d9d0316c6c55b66645724fc20ada330d102ba116c509249ba0c8ba94e" }, { "category": "catalog_contract", "entrypoint": "bundle", "expected_failure": "noncanonical_int32", "id": "bundle-noncanonical-int32", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a2f766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf93ebddd3ee7cbd9fbe49eccd8f732af73a12b5c4e9be58431877c9cb6f5e6fb214bc0a0476f1333796740efd4a53f3d83c21c4be9fc51c71b9fbe11d126caf8ea5f7a32433bca84d4031ba83cbcc7ddbc7e38db5dcea225f5faa567b48ede7293779c8b404d25223772a1887afd243a11e57d22ce2e065695548f6d08b56d2b8667a97f74fe0372a311b81a406ab66f8a2bcd8277a5789e018d8f004b9065bebd11269e3906b21cf81b9b971c98849146bc40e7b202b35c02c3d2fc35689745ecf1c859e411c00686a1723bc83e1b7b97a12203311eecbd2e50a0b1351e427f1285acae5fbb6b4563bdb2b716c89362e028de606f9d4a3b9a1e8cc756311fe4b0a2b3c060850d6986a7889c6b8a9f6da7661525528b4fa2295f0c00fcb6bedacb668a9cc5bc887ef92c9b00079159b2b2fad9fe7dcb314cf69960133465491c1fb09aecccbc18881439a7a5461f259f14ec7567871e682292b47db0fc2a798760a35666f32c1b98ab3e53baa2132a2bc0c783cb72102a8d8ffb1f45a8a4b4dfc03595133d9521879f00096e060f8d8db287b437cf24cf3200d5c204dea39d8c4c6cc82ca3a1ac146732359f8c926f12ee49e745be208a53a28f3d8b39c2e7ba7b23f6018756829401762d4fcaec90287a5567c5f0107f1340f566911a3bd7721c35704e05acb27d9caeaaea4439d0118e8ea97980c354c71575d8ebebf780c9895e019355964cdb0c05ba99580e937f5c58a03934229d03c9e945e6a893240a5fabc4f09587034ef945405c75941b66261fab324f668200f9b4f8f17db8946bce63657e999b9a6ac253b823e4c3019149426a9c2880874bd1a234567e2cfeb522e143e89b8bc978a1d010daa78e9e81c083df71e07841edbc1cd96db92bcbe2077266a34e736f76842f9a64581cfa9ea15d762a6ab90955310f863ca4c1a17bea9a8b1faf62294c7aa08a5916d3a4222074df0408846dbcaabdfa3e83f0e3644fa9e61c4cb70245f91558f4129e4be77414c41c237ddbcaaf7e4e1c26bf5cac46eb7fa77b52bfce1f153ed3e2d35fa7af613b24afce7981cde7741e3d83c122067a636ee6b3b6b441a9a2db5d0632b6d0ab56dd3f12901c1ceea4237a87c240abad64b20cc6251270a6c86f674a7d84381e9efddffce6595a3597dbfb2b3deaffdb3a13c7ffc353ee568ee0542b1ed6b947cfe66b5b5d3f4a2616e9260d746f1d5353f00e08d172312d1d54cf85223f17820376ad5bc869c03e4cdae9d230102ac908cfcda6ae2ae16ad3475c545ca680caef091ce2f163dce277e737da9fc14648daa5ae555d09e44e9ea466063fc7629411e84670ba004c7aabe63a9c7560789916497a8225e4a24947baf89730d4b6ad43a201a1d740c80065f74d7a41c9957de7ae025dd5cb0517a0ff4125b49ecabad79fc0dda0b7edaca1d8629c5536f146bb54821b794299d949c51ecdc72f79a9608d07232885cde31cb4cce3a294529a3421cdc3cc70aef07293e16131eebbb53685978bb86cd14b6691074e7934528b797b1dca984d2dac3503acc53ae1b1fa378dbc1c01c757c13c5e1c2fae7c40495e01c6ebbbc3f2c608c73c9f870ffe2f5b76f6b80a2bb3c07fb6152c35c8fc85c1b6eb7149bc34ef20cbaf1fcff82cab29350dee9a8d50f03a93e2abda9c20c53c2481e8ff6f824126a4d425dab956958b38b6fa01ae60e4523d50fa435000860326c7e773b50b05b43e8f68ed2f887794d2693628f9589a58b5c56b95ebea330ba0d3fb5049c677da1391f5410e19f4c1fb7bffa38210044c057aa25acdc14b9cfbeb916e831342bbc2bdcf6b60e6e979a65f19182b5614c852e9afaa215c6219f9dc653461cc2ecc6783a78468ee28baedc98d457d2f8160c77bd2bb6d52499f34c86c02ba18ea0b1414d1a2525d8dd8f958db835dea5a5d3ed14138febb9138f7cd02c0fffb516b00529a4af61099ed9f970eb1a73f1bcde6b17e53bb4dc8b0efed07e836aa44532bfee7e79deeb4ca3cf1bdca0ce12a2bb9b9c52aec8a7eaad98c10067b9448cf00794950badea217853cb0b44a7262b1d0abc490426f5ccf567dfc2dc5c935b29608b7b096d821472a6ab3c1d1206efa835c62155d8e94021dc3f6482532f84aa27e323de681ba83974d0da1247dbe10e0b3f32d3378c9359a710fd01d8dae89c48a4dff704e8f97d51894701a961065fbb0cda7be4c698b702cc61f5fbb2f804178e5c7bdbb4e26edd172896d3d7c03e87fb7ba3674910c50dd51d302f40cc75e5c26b20634283696bb1e241cbe0f15554a91d726568e96134a460f2ab2970ba921a8cc025028e7061039ebc84a68b6cdf6962aa58252e47a82a1a764eb49e3aca0e8a370036a3ab8cf6619daf80b957a584a00b0d1c7e67f01d77b45cf8190878c6ea7a44b1f7f5e6669ab9b656f77ae1092bdf0eb713d7f1ef24ae090a8a21339a9c41daa5de6d4a686af029c038f4197a438ba9eb6a8eac97b20fadd3713ee17ba3480790f3100937b213b8ac412b5e423bdec2054b55f4d474fe56a1d4f8497da4ff953bd597d5ba765c1b4f3dd9ea09c9777bf58980a1f0c9706cfb132e81ef1ac64508f6a5f0824afcad00733b254bcc71610261fa23df875c5781e1147804144010505059cd34a3e76ae6453830cae15ed81de100841a33bbe82a01c214250e294bfad523ca691326e5d9cbb27d874944103ff661fbfb7cc8e766faa3eea57ae47b56ecf0c4393a8187b090c97463fd471cc7e617d313c430a2b34cac9ca4fb6947750b95fc254012cd241f7a5c8e12c3b9536fea22cea59683bf6addcc03e659df7616b97b81ae1572331ba30c5ced379e56020282528ee1c08ee1c07447b090f0905bdafc1af480116e286d98e461086e13cd7ce59cb8a438d75a41ea4fff4eadf3d2c609a35bb0bb766c67380b781996cfa8f041046d0ecbe88ee2312d1065148ae6be7add01ca2d17cf6c20efb81fee72b86a32f86f3799feb0898ce01bee6ed22dff79e4aad15f5ec9461b3499dc1708de2fd33663ee30f6e811fa40a93ce9eca209ad0337edd091dd49edc2e6fb9933c6b44fc63b56f143f540af20c78be84050b58f27b6682f3974c45392d3cff0bffc109ca579e80a565a85087f01c6e8ff8b5d69559dc65c3b553516b06fcba9f9c6b1ad40a7081ec5f3c6aff3bae20ad6fb0c8fa6fe1174de3640854b7a32143edb1b770eb610e98dfffca4facad0372d8c232d14b24da8ef0be291b8c20c2fcfb23c0bb14cc869dcab38cf1c65f6e35cb37f9e211c427cbe06a4d76071b1392c5739e579040d3bf342698616f3ee8c826d2167214977d183a11dab4ca2787e238ab50aef4dbe6cd1c10312f7e7f385550c34a4dfe6d68b230c93b90d86a1edd184a8e0bd4eca5620c22cbfcba9bc04deaeef576a600b576be1d9fa1fded032568222f7dec9667145ae16433b67fd94824a6a1fb70481000a945c94bd72213d55ff0a2dd932841a411bf1c5e01b47057fe399191e2c22998f21f516103940568a66fea1840a2f08b7791885b69a5b8ffe97f86825ffd503077b8a4e953ba8264255091ed6e3ddaa150b4689e799fb8396206537d2f83cedced062c50d6ffe34a882ddb7fcdca4dc95854d5edaae6d687d634c57f67bd46b426d08fb18f5348516ddcf83a0a23881499013e1f5d5012b35daef40ecd2b38ef10bd1b4647de00af2a21cfd02c4d952c56f4c23fd9cd8300308f4d30f94798f0fde31396668c27d174683730ccac171828b29b6f6ff5860e095262c261e6fe45364aa15e87174c8270a7973a15a5c8c43a1b55b6ea3a87831cd1b6de2fbb8622cdc1dbb172e492ead64f47cf80a763a850f4e17b1019a1a1d0c3952a77f49c989905bebd9708d998b98138fb348c34b5a410f08b716fb8b3ca1a9cf673f71a873ab85903781630a0bdc2f6cad9128d67f1ac966d42621ae0687906f5ed6ede46575e2be86abe660724616c2c472d6cc86edafa3bab3928ebaedd48e6d40a666bd0af44b50feef4ccd779400ff16b230afeaf47f779fbabb869722e45a9bbf8f8a22e2c1b0928ff4d52526d03edd2ffd32b2387afd83b24120a2354d6d4aff6791ce41eb7162c0d743809632000351d574d9ac7886ecbf1b2cb3486a8dced8cc5752877ca55975ec7e03dc60cce2deb9c089970669cd292bda1db1fb10e44ea21ce91d36d8b92727f32638be4577724496bc318592898b68c0ab705cf8ca658aa996aab30e9b839fa775ea26de6e3c125616f760b09f4ceb4cf964f82617b6654bf4b351a275ec4fa3198c2b5d153f25c97a78f456b36157f7ac1d9b6d480e5ea3c39112b33c9eb5d9a68c91518d64aad8ab0bd0b01cd211c415c30cedc8e3" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a2f766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf93ebddd3ee7cbd9fbe49eccd8f732af73a12b5c4e9be58431877c9cb6f5e6fb214bc0a0476f1333796740efd4a53f3d83c21c4be9fc51c71b9fbe11d126caf8ea5f7a32433bca84d4031ba83cbcc7ddbc7e38db5dcea225f5faa567b48ede7293779c8b404d25223772a1887afd243a11e57d22ce2e065695548f6d08b56d2b8667a97f74fe0372a311b81a406ab66f8a2bcd8277a5789e018d8f004b9065bebd11269e3906b21cf81b9b971c98849146bc40e7b202b35c02c3d2fc35689745ecf1c859e411c00686a1723bc83e1b7b97a12203311eecbd2e50a0b1351e427f1285acae5fbb6b4563bdb2b716c89362e028de606f9d4a3b9a1e8cc756311fe4b0a2b3c060850d6986a7889c6b8a9f6da7661525528b4fa2295f0c00fcb6bedacb668a9cc5bc887ef92c9b00079159b2b2fad9fe7dcb314cf69960133465491c1fb09aecccbc18881439a7a5461f259f14ec7567871e682292b47db0fc2a798760a35666f32c1b98ab3e53baa2132a2bc0c783cb72102a8d8ffb1f45a8a4b4dfc03595133d9521879f00096e060f8d8db287b437cf24cf3200d5c204dea39d8c4c6cc82ca3a1ac146732359f8c926f12ee49e745be208a53a28f3d8b39c2e7ba7b23f6018756829401762d4fcaec90287a5567c5f0107f1340f566911a3bd7721c35704e05acb27d9caeaaea4439d0118e8ea97980c354c71575d8ebebf780c9895e019355964cdb0c05ba99580e937f5c58a03934229d03c9e945e6a893240a5fabc4f09587034ef945405c75941b66261fab324f668200f9b4f8f17db8946bce63657e999b9a6ac253b823e4c3019149426a9c2880874bd1a234567e2cfeb522e143e89b8bc978a1d010daa78e9e81c083df71e07841edbc1cd96db92bcbe2077266a34e736f76842f9a64581cfa9ea15d762a6ab90955310f863ca4c1a17bea9a8b1faf62294c7aa08a5916d3a4222074df0408846dbcaabdfa3e83f0e3644fa9e61c4cb70245f91558f4129e4be77414c41c237ddbcaaf7e4e1c26bf5cac46eb7fa77b52bfce1f153ed3e2d35fa7af613b24afce7981cde7741e3d83c122067a636ee6b3b6b441a9a2db5d0632b6d0ab56dd3f12901c1ceea4237a87c240abad64b20cc6251270a6c86f674a7d84381e9efddffce6595a3597dbfb2b3deaffdb3a13c7ffc353ee568ee0542b1ed6b947cfe66b5b5d3f4a2616e9260d746f1d5353f00e08d172312d1d54cf85223f17820376ad5bc869c03e4cdae9d230102ac908cfcda6ae2ae16ad3475c545ca680caef091ce2f163dce277e737da9fc14648daa5ae555d09e44e9ea466063fc7629411e84670ba004c7aabe63a9c7560789916497a8225e4a24947baf89730d4b6ad43a201a1d740c80065f74d7a41c9957de7ae025dd5cb0517a0ff4125b49ecabad79fc0dda0b7edaca1d8629c5536f146bb54821b794299d949c51ecdc72f79a9608d07232885cde31cb4cce3a294529a3421cdc3cc70aef07293e16131eebbb53685978bb86cd14b6691074e7934528b797b1dca984d2dac3503acc53ae1b1fa378dbc1c01c757c13c5e1c2fae7c40495e01c6ebbbc3f2c608c73c9f870ffe2f5b76f6b80a2bb3c07fb6152c35c8fc85c1b6eb7149bc34ef20cbaf1fcff82cab29350dee9a8d50f03a93e2abda9c20c53c2481e8ff6f824126a4d425dab956958b38b6fa01ae60e4523d50fa435000860326c7e773b50b05b43e8f68ed2f887794d2693628f9589a58b5c56b95ebea330ba0d3fb5049c677da1391f5410e19f4c1fb7bffa38210044c057aa25acdc14b9cfbeb916e831342bbc2bdcf6b60e6e979a65f19182b5614c852e9afaa215c6219f9dc653461cc2ecc6783a78468ee28baedc98d457d2f8160c77bd2bb6d52499f34c86c02ba18ea0b1414d1a2525d8dd8f958db835dea5a5d3ed14138febb9138f7cd02c0fffb516b00529a4af61099ed9f970eb1a73f1bcde6b17e53bb4dc8b0efed07e836aa44532bfee7e79deeb4ca3cf1bdca0ce12a2bb9b9c52aec8a7eaad98c10067b9448cf00794950badea217853cb0b44a7262b1d0abc490426f5ccf567dfc2dc5c935b29608b7b096d821472a6ab3c1d1206efa835c62155d8e94021dc3f6482532f84aa27e323de681ba83974d0da1247dbe10e0b3f32d3378c9359a710fd01d8dae89c48a4dff704e8f97d51894701a961065fbb0cda7be4c698b702cc61f5fbb2f804178e5c7bdbb4e26edd172896d3d7c03e87fb7ba3674910c50dd51d302f40cc75e5c26b20634283696bb1e241cbe0f15554a91d726568e96134a460f2ab2970ba921a8cc025028e7061039ebc84a68b6cdf6962aa58252e47a82a1a764eb49e3aca0e8a370036a3ab8cf6619daf80b957a584a00b0d1c7e67f01d77b45cf8190878c6ea7a44b1f7f5e6669ab9b656f77ae1092bdf0eb713d7f1ef24ae090a8a21339a9c41daa5de6d4a686af029c038f4197a438ba9eb6a8eac97b20fadd3713ee17ba3480790f3100937b213b8ac412b5e423bdec2054b55f4d474fe56a1d4f8497da4ff953bd597d5ba765c1b4f3dd9ea09c9777bf58980a1f0c9706cfb132e81ef1ac64508f6a5f0824afcad00733b254bcc71610261fa23df875c5781e1147804144010505059cd34a3e76ae6453830cae15ed81de100841a33bbe82a01c214250e294bfad523ca691326e5d9cbb27d874944103ff661fbfb7cc8e766faa3eea57ae47b56ecf0c4393a8187b090c97463fd471cc7e617d313c430a2b34cac9ca4fb6947750b95fc254012cd241f7a5c8e12c3b9536fea22cea59683bf6addcc03e659df7616b97b81ae1572331ba30c5ced379e560200964993edcaebefce2241a39fed8a46c7fa1be9d1090f0834dcf07c0a210bd517e181a7b93ec65b27e441af59a9e1cec745bb43d1b777646836197f0a83d2aef150087700c2e79be3363a0a6c079dfdb175d100ac2319997124e7ff07af2bf86732e3441b8fe8b0938ae12ad8639435e739e9aaca0605da400c2f848c1906912dd23778f531f1e22dd15dad34d3ddad06ac1f66f5c882dc49ffcce8ae8f3ec68764db3f5ecb69f4579312c38ee8454ae4973fa5692f2b2b955dc4c882e8a79d1c9dad76e6125f499213700ecae999d40c3e0cd9d241391a2d0ed163caa1f6c8a9a453b01311caffc7c9fdb4b21dd2e9108eeae60915d2375d934060391929c8047b1cbe00f698f4f7a59cc4de6130882b6a0cf314e8e20bf9ba94d41a2fd5b43d3a87418e71c4eb35cf073cadf05aa4648371124c33bc07a5cc7b70b633107c24e1713e3c34fc4375d70eebfe8e827f2f69745f7ad1b08a00af4498527f34b6870eea4dbb7680d91b02f5e7e1da040262bf92feceea2f0d8eaf069caae2c48fa2edba47af09719575fbdfb4be4bc6b68e5b6b7d1d5c71ead6ef14d4dd355fe72cf98ede626359af5a5b2306f1959f5c6105bc1a83000c815885bc142f3303e20e259e2ac5430116f1decb3751137ff99f182f1a2fdf9739b51b1022190d9960e9ba995a2106f87b1984ad815a88f4abd37521f7ef3208748b52c423b03741551b10d8b6cba715386d94e391c1a997375905d6fd3ce8d4817add1c6cfe26f7d3d3e1e791bcc4f6874d58ccae61646b724d528424853c1b291ce61aba2c9d75dfcf85b6a2348d5f8112e487db0f7b22defd5ced9eab969209d1b2727dec05f0b51ff707cad64ece605d31fd8ad9560d06a2ce0b9c3e974e8771346673e951015083690acbf0478fcd31aeb6f2587bb9ce352a310572b45d6ae517e9706fd3260d734f8a4758847990ba546fbff96029db1f6df0f5b6373ad11d883c334d26974ef56bc438723f850a5446a9198c1e1d1e6603a929528491882bfcdd7590e2d4c944d6f75cde4915591778a012fe9647afa79f703b63b472e79d884590700c16d62575bc9e32dd731ddc75d23720c80889c6725adeaafc242ca2b386b0cd4c645016d8c273e7fa8babb7bbfabe9295e3b6c7887a5bbb36b304bb4951fff457cc709e3cd40bb63895dbfb707683eba39e8027e44895b1da9c2fe2f29b8f8bfcef0f27c702ef2bf832b7222be59ba54420b06a1c631ce42a89d631ec6a62c7d04ae5c963260d15d26bd8e123c0db8e0a16927442da8592e141568566ae152cab0e7caf05a7cdb7dffab0a42a5eea1f10c83596f72cde6adf5da20533dcaf2727aa356ae11a330f1bd1a92d9e9086a0239eab3a1fe8d1639de190bcfd089182d8ea25e4219d633d135c13f112b2c703a231e777f92d11f73a1be5b91ab034ac04b11694187404243bc54a63a20fce3b1cb7e204842e064a03e19e830aec71d2a5c4ebdfd723b14c6995c28de2f3d61e710a02bbd4cc5aa8a700b4f506aa9cc1a1a02581074e9e942f25fd87959814ecd66c5173499d760fea3434b2e7c4a01d35f9454e7dd62543f4bc4b8b03c92af2873414c4855a7f434d15f85004bf28e53fd7cdecaa0357eb7111955d9180f8e98a8426675069f4833ea96b333569defa5795827854a8d0877d73481ae51e760b8f46cd8076c706ba79fc064be89fc9327b25735b2a1d7073b6782ad3ad464b7d0eaecbf2ddeea397acaf871df814630a" }, { "category": "catalog_contract", "entrypoint": "bundle", "expected_failure": "int32_field_bound", "id": "bundle-int32-field-bound", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a30766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cff4b2e11862bef2951545fa91151d4fd609f814d834ef0547ccd517e5799336c63d7fda19621e4ad868dc31269cfd1cab022460b455c4c4dba311fa03e08a0093406dfd727b9be1e1d0cbc72167574fc94ccf0a95545fa77b210a4d8e311ca854e551191f7b29880dae1598197abdd9457f534f69471b4883a043f2d14311523592d616f16b8d88ec53cac4288e9ba13176e98d923cf82c60c1a5caf547729f1ffc1b0fed649721d1a7fd026e21ae6b7a23c914927344dd45cb9ed27e51ab58301e7a299eaa2852cd00a16a1d4cac255f548451c078e290ad5694cc252302ba13edab28bf1d66d4cee9b0d251e242e8af70b9a2aa210f6c0c458c6e0c418401e2cdfd65c3fe2d2134a9527c1b17f472d644eeace376043613a9d0b225e393995cd183e54f73565f87027ea43bd8642d951578bd8312cb7a627224b0887f88d9c9e1d138ea4f8282c116ae162b016d03cf7eca90ae6fecea82443777ff46ade2fdf1be6f347e13c66d266cccb2ba4795c9e8173b7f82f41d16aab11a2fc77f4b36943ba56db93bd8f9dbf532501f3fc322ef8ba1bd3105d5c3f44aaa0b25de1efe3a38d52ec42c3940ff84e784fe36bc97ec1e20e2a91537555ccb60c2bf41adc8a65c25543f15390984ba61f7f8ae7e1f9304d6ddeadec5557e9ee46fe47ce7bb461dee66f05e544d8af1c0e289fe74dc3e19d6813eaf9eab7d7771da0ac519bb632305fb6910f74b882cbca0dde48dc518c8bd59327cc301e225ae853ed4e2130f16dd8849a70befda9a55154bd4cfafc45c3a621432692f35ee8a492d176655e0ebeb50d4bd192f5929b29bd47a76e5d316dd4e44619a1540a05e2145a8caaca881742ec55f3112078cbf430765a3dc8e7d7a372cb0991913bd4b39573adef2f1978f5888f8fbff65b60cf3f905531cb165c0361e83e834783ed82ede49024c2046098aa5e878d88ca3db2a2a32a32c61ab53a9fbe47698e25867192f1be13eac0e57836091ba61d18b587ca07afe816c023211a72e2b10f6ac3ff4e64837f43f0c8fb3c9453112e8b40e31fd5e637fc7a9641d8193d4084de50fe75851414e06d40f4be93601e02484fb96e71cb46636b8b89414c4928c6dd19d3f99a827eedcb1a0d36154653061d359248f797da124e5f4a3c920317829b1be3764334818088f0ff34ccfcd7be87ecd42777e677b4b39c4c633f46752fbeae872d1a9673c76e8a1c2d00f62c7b36b14767c5be370178f37ddd06da7bab043119d25928bf32164375f14b17188f63e5baf7a0a9fa8c6a88459d44cbc6c77ec1a70a779e266d70b3ce81b591455428e66b1da4f136b51bd22a274494bed9187abb116f176added6bd46feb9b3012c82b5f7b75bc0c5617e84b7375a7f6edde8e58c3bb40f60182d1b91c1b895c09bef3cc733eb7a9b295543e18a29fa07b175cd6c25b1202eba6dc70155ea102c45a36c9f1c011a741c7d390f3790c86d2cc168e3be2a36664b57fa2ad134bdafbe64b9812c74abc6f861923b646adc62b85ea766f6e83f233a0f6eea226fce5536210b276c8f878a4e8be181ad453a83ad293b64d231af4326509ec3f9a60a6394c709f3b2bc6112a51134df42867f2bd0a2221a2f23131f9fed3ecf9a35082f7a55bee5053df1824864e5cddfa3af3cd5f9930213a89b9777aacbb219660173bc4d89005967fbf1cf749f00fb3a6657f71640f6381e5163be64b68d2d59d5861edeb13f845c5a92388f356c50881e06874a31bf978270034b675c21ae7f8cdfae7de446edc019810c60d05929d472ca3ce928085bab4388307f9bcfb2f3d6240683202080c1f72befd11b168f33a68784a2f0e0ea2a554c7eda0ed91ca67deda5235074cd3c5f184008c30df7074d962817c101a721f71d9ec0f5e74cb45c731dca1e5f08d0b0c54d70bd6809eac8f5ea6429ed7446966a86e7e5c942e863792bdcc7adff081e7797bb7e540a0c5a35d421a26283ab854d263019206f9fec35a1627801bcf235696de1c3542b4b5e3f6dff07b4eac453756a83e00508eec1fa467859cb2b5cb8b07d38d0060c084800392d06b61819d8bf186038f7131aca5c6421c2e001e9fceffe319195bdffd59e132da0c1fce8173f46c76ca892cb8368174406aecfcea227ae1dfc42a7a9d325c24060c9be0a57bbe0ad3d323a16a28cb8a63defb1842d2f75e7fd37c1631912b7cb6119ac4d8294dfe912ae5ecbc0afd20e6a5ca1d62cba7e71fb4e71121dc40d86ff0018f33d38557c0dc0ac862642056b85203ff87b8026ea14c895a7a5ee7618f3afae7815609a22dc70171cd60e3026ecf5db920b9d2ab44902fb5021f7c2ef29eb0c03b256a34c3f1d14c09188ff052b9f19645f88eb0473e8c18173d487dfc8dcc480877b5e8a01dc6c1ee1dfe2fa14a9dee5eb01f73e02fc950e79941cfe771414777c3e40c36a6a11647a46e5b787ef6631f6ad18ea8a08befb5050876aec514bfd7087f44c5255af9f576b4cbabb5a8f938462d5805cf7820d546d2d7deb5369d75f10f463cfa990b25b7e3cc65353a8a0766b5fe1cf627a3293f4f5abcfc2e7ae58be318f46711e76b52e6ba4595c6a8ca65803802a77369b4aff014e2a5e9b11815b10f5ffaad40d94d0098ec6e98a28ee2149b49b5138c818d9a01aaf86701b4f3f9fcd60d4479ab06637c1039268eb51dee531c125069ede9585d79cc27d55ae817f88e6b42fdb333852d15d19b39a5aad68a5f07e28adcc4a3f12c24f695a607bd0a69e68dcee62c1befe98027366cdcd47702f2abc084d185f87c7533c7190f52474908269d9a7ba2004a7026804604af2aa3455142349063a049347d5e2f2e1b12e3e614163fab33418c1a2fb6793bcd0013829f219197d171f43ac94e759b9eaffe404041f9a689192c977da03ca36db9f1aa3e60f4feeeadf30354977c36dd54dcf7de166e69ccf141ecee40b87e7b98f9a2c7a970271d48b26c624c53bab9d0ce6c6f514ccbcbcd7f7de2723fdb0b443e1c7cd2796839f440401b613c60e7361bb3d1e83f8772947650503824b2d2cecb65159b9512c1eb6b956c305e7a114266986ecc7d35746a2f2a7bec9ed0ef9e91f132b947ebe43712c976f442ac2a81b11438da0ea942f7ba9d4a4adc7312d6b9f8eae9e64580e98a1f534716cbd297ebe483bf4763da5c7012f47593535037d20768c718ad03c4e8204d94c1f44492805f61ce960d00ed8604ba38da3447acbda6bd8390416c35844ae0b94d38b36b6f528b62b1eab29fbf96025b3f620b45b7f5f84aba82f0be0e07507b14a12f95ef95d091c775da8e33a080035c02ad5c5f4d2f6399f0cc8c97425c5ddd4b2e805bf84ebfa8e82910f24b788829913d589c7b653cbd1d5aa8226f1ba1a85eb2626aebfe9c26c32df666678c6dd45eb076b88a2d426582f89aa162f745df1b4894c2129ff1c2351df97b05c1c6795cc5010ecc49f0e7d4a5a7cb3cd3ddd18ecb3f46114fca729962a9ca5eda694064c2606cf40b35ca6d709d56d0ea860f692cd31de095d854701814122e3b6ca8744c400a628f2ac409fe8667d37d3d4ea8fe4c377d25717c885c732fd92f7d32774979096cd4fd6914274f38e50d31626052a6586d8672e6680f065d9d3b7269716275a367b2a10f55c9e2a97b7a29e1a63a4f3c88026eecdf6f40693e8c89eca19339a138bab0f67ead4d5bab848050e1d7d9c0ff78b01152aebe8dbfcf510e12bcdea45bc41faa2cf705d428ad81acaf5b58f20c5922d64596ca11869f9f9f72aa3a941d271d272aec2f4f5ff8153ab038c99ce5d1ba808e168c008bc0e04697284cfde2a740035429deda84ad4001227864136083610a407f42b2c340eda362641c31423aafd5790e2a67aba51eb27a5e56e1f7a1e023e5a956963cddbedbb8930c2d205c4eef9ebbe3fc22fa966ed3bffac1c87de2530a07d90519cc61c6e610d924395fe737e08ba8ae494a6ab1b8d4cd37b0d8f9522a2282dab77c1f8630cd117925b6fcb38af2549d4ddf8bff68130470cc0129c0abaee7ba2e5c8571095b6b1ebe267322a9cdb3b43cc4b3570c25bb1b25a7f194876dcf5c366b13c6b3481816d622e2bb5885212dc8c8aee38b4d254100a23da3749fef9225658c3322d9d7ac236ce6d005e303027f890f11077e2a51267c780147dfdac99c5f1a4f9774687dbe8c7059cb77b93d83cd3f09d6c5cf3c86d3b4b2ef32e93c184a50f18a056f7b56cf62f5a3c42a6222a401832e64835a7cd47e02984584458500d97c4d5599156daec85260108a7b9373e745ddc5cba06fe0aa7fe6112dc5a894f09731af8a515e3fc43f3411aa73aef2386a6baa7fc0b3c39c0786aa11cc7f6d949d5e7bed46ebfdba0c70e" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a30766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cff4b2e11862bef2951545fa91151d4fd609f814d834ef0547ccd517e5799336c63d7fda19621e4ad868dc31269cfd1cab022460b455c4c4dba311fa03e08a0093406dfd727b9be1e1d0cbc72167574fc94ccf0a95545fa77b210a4d8e311ca854e551191f7b29880dae1598197abdd9457f534f69471b4883a043f2d14311523592d616f16b8d88ec53cac4288e9ba13176e98d923cf82c60c1a5caf547729f1ffc1b0fed649721d1a7fd026e21ae6b7a23c914927344dd45cb9ed27e51ab58301e7a299eaa2852cd00a16a1d4cac255f548451c078e290ad5694cc252302ba13edab28bf1d66d4cee9b0d251e242e8af70b9a2aa210f6c0c458c6e0c418401e2cdfd65c3fe2d2134a9527c1b17f472d644eeace376043613a9d0b225e393995cd183e54f73565f87027ea43bd8642d951578bd8312cb7a627224b0887f88d9c9e1d138ea4f8282c116ae162b016d03cf7eca90ae6fecea82443777ff46ade2fdf1be6f347e13c66d266cccb2ba4795c9e8173b7f82f41d16aab11a2fc77f4b36943ba56db93bd8f9dbf532501f3fc322ef8ba1bd3105d5c3f44aaa0b25de1efe3a38d52ec42c3940ff84e784fe36bc97ec1e20e2a91537555ccb60c2bf41adc8a65c25543f15390984ba61f7f8ae7e1f9304d6ddeadec5557e9ee46fe47ce7bb461dee66f05e544d8af1c0e289fe74dc3e19d6813eaf9eab7d7771da0ac519bb632305fb6910f74b882cbca0dde48dc518c8bd59327cc301e225ae853ed4e2130f16dd8849a70befda9a55154bd4cfafc45c3a621432692f35ee8a492d176655e0ebeb50d4bd192f5929b29bd47a76e5d316dd4e44619a1540a05e2145a8caaca881742ec55f3112078cbf430765a3dc8e7d7a372cb0991913bd4b39573adef2f1978f5888f8fbff65b60cf3f905531cb165c0361e83e834783ed82ede49024c2046098aa5e878d88ca3db2a2a32a32c61ab53a9fbe47698e25867192f1be13eac0e57836091ba61d18b587ca07afe816c023211a72e2b10f6ac3ff4e64837f43f0c8fb3c9453112e8b40e31fd5e637fc7a9641d8193d4084de50fe75851414e06d40f4be93601e02484fb96e71cb46636b8b89414c4928c6dd19d3f99a827eedcb1a0d36154653061d359248f797da124e5f4a3c920317829b1be3764334818088f0ff34ccfcd7be87ecd42777e677b4b39c4c633f46752fbeae872d1a9673c76e8a1c2d00f62c7b36b14767c5be370178f37ddd06da7bab043119d25928bf32164375f14b17188f63e5baf7a0a9fa8c6a88459d44cbc6c77ec1a70a779e266d70b3ce81b591455428e66b1da4f136b51bd22a274494bed9187abb116f176added6bd46feb9b3012c82b5f7b75bc0c5617e84b7375a7f6edde8e58c3bb40f60182d1b91c1b895c09bef3cc733eb7a9b295543e18a29fa07b175cd6c25b1202eba6dc70155ea102c45a36c9f1c011a741c7d390f3790c86d2cc168e3be2a36664b57fa2ad134bdafbe64b9812c74abc6f861923b646adc62b85ea766f6e83f233a0f6eea226fce5536210b276c8f878a4e8be181ad453a83ad293b64d231af4326509ec3f9a60a6394c709f3b2bc6112a51134df42867f2bd0a2221a2f23131f9fed3ecf9a35082f7a55bee5053df1824864e5cddfa3af3cd5f9930213a89b9777aacbb219660173bc4d89005967fbf1cf749f00fb3a6657f71640f6381e5163be64b68d2d59d5861edeb13f845c5a92388f356c50881e06874a31bf978270034b675c21ae7f8cdfae7de446edc019810c60d05929d472ca3ce928085bab4388307f9bcfb2f3d6240683202080c1f72befd11b168f33a68784a2f0e0ea2a554c7eda0ed91ca67deda5235074cd3c5f184008c30df7074d962817c101a721f71d9ec0f5e74cb45c731dca1e5f08d0b0c54d70bd6809eac8f5ea6429ed7446966a86e7e5c942e863792bdcc7adff081e7797bb7e540a0c5a35d421a26283ab854d263019206f9fec35a1627801bcf235696de1c3542b4b5e3f6dff07b4eac453756a83e00508eec1fa467859cb2b5cb8b07d38d0060c084800392d06b61819d8bf186038f7131aca5c6421c2e001e9fceffe319195bdffd59e132da0c1fce8173f46c76ca892cb8368174406aecfcea227ae1dfc42a7a9d325c24060c9be0a57bbe0ad3d323a16a28cb8a63defb1842d2f75e7fd37c1631912b7cb6119ac4d8294dfe912ae5ecbc0afd20e6a5ca1d62cba7e71fb4e71121dc40d86ff0018f33d38557c0dc0ac862642056b85203ff87b8026ea14c895a7a5ee7618f3afae7815609a22dc70171cd60e3026ecf5db920b9d2ab44902fb5021f7c2ef29eb0c03b256a34c3f1d14c09188ff052b9f19645f88eb0473e8c18173d487dfc8dcc480877b5e8a01dc6c1ee1dfe2fa14a9dee5eb01f73e02fc950e79941cfe771414777c3e40c36a6a11647a46e5b787ef6631f6ad18ea8a08befb5050876aec514bfd7087f44c5255af9f576b4cbabb5a8f938462d5805cf7820d546d2d7deb5369d75f10f463cfa990b25b7e3cc65353a8a0766b5fe1cf627a3293f4f5abcfc2e7ae58be318f46711e76b52e6ba4595c6a8ca65803802a77369b4aff014e2a5e9b11815b10f5ffaad40d94d0098ec6e98a28ee2149b49b5138c818d9a01aaf86701b4f3f9fcd60d4479ab06637c1039268eb51dee531c125069ede9585d79cc27d55ae817f88e6b42fdb333852d15d19b39a5aad68a5f07e28adcc4a3f12c24f695a607bd0a69e68dcee62c1befe98027366cdcd47702f2abc084d185f87c7533c7190f52474908269d9a7ba2004a7026804604af2aa3455142349063a049347d5e2f2e1b12e3e635578e7bf3e7dcfacad6180b3cd1b2534f418042c0f7e63f5d0f344bba5e7fc6f000285759783c811d40d9a21df8d06a2aa17578fe6de383a4765c47ec050c97cf06afc92c61b0bcfe31c9deeaa988123759b0317da8570db20cd7cb3dc55a6cbf50fe790aac1f7c42122cc86b905f9df8d2a9698c61f1707a108db5e7d6ff43f1f82e36c28406f39289f4f2516ddb95e58a5003406a5b856d10be9f4022b69b5c7dad75f7c88f3b082fbe90bf9ec0542338c6dbf436373c66112318fb003704f8ca8e59a3772bac3bfa2c83d2c0dc0dff9c7204c139aeeed2c1060bc2171b05032e76126aac80d2e21573b651479326a8d3d92c85d428319cf47054f443f16e910fb68c011cd6b40a2ebad4a2cd6de639f21454f835544b520b8c59d36acf329871d13ed31e3b96d25a8af7ba75d684499fba62144e2e1741caa4b15f362d2400d144d6ca7ec3b14512dd83ad7c5e6dcf86c8901c8fa5f38c1d5ee98e91283b6efff8e878b5e3caf988099018ad8c9e04886c9cdcb8e27ecac119fe02933a5b5e4c565578037312182c958e9487390c3d827453982b01d206156fbb98e454427f14c3455cd8bb110047ff4c0510dfacf850563485a7aa6c92cdc7ee2bda27c10f4e031d773dca8edbdbf2a5a3006d546b26d90da4fcd754ea560edcb9622d81e4698170f8f0e20e2b7d19c45c30a9a03efa848a5d9697236cf85f297f5c077c059199d93ca32ceb9ead72f4ebd9181f17dd2944271e9df48cb072505383ad7d631352b98e806dbd3b130971a2b4c277a2f62aa0dc973d3a3bf9c557bbc90ca8020f8cdfaf81082e9cdecc417638d1799b70e2bf2ccb6b8b84e130e11729e18f481041b25f7e3d4ede710f72aabe44bea5cfeaa88681c1bcad51ad1de999936c5882b65685aac5e71e1b9fa2ab8f01ac177c569b392fafbb08352aa189798c957278315e560fa39b3010575239cd7c82e74123b4cc8fba54ae72b0f238e7b1c09212c9603f12b292e5fc22e3045c3067cfbf3018bafbe62ca46ef22b89e314e2d47462a4797267bd5abfabf8c2db9dc0b94f9fdf9a23e8e37b114fc28f9b1168dc7343fba769c5689d51a7f606b9c4dc3e377764fa2cbbdd4abab00a660c56d0d959323931e20ed6fd9b86e0cca4ec94869dc23b27547da92fabef79a2b460bca2eb717bee64193eac7560cc4aea9fce7672024928e2d4ecc781e6dc6538b985b68257a72d9f5c67ce024732384817f3d7f25e3931f0ac4fc8df538b3d55d755572fc390df9e6231b0385051c8c40e30ae63a4b141d2623fa8afa5c2ea5d56d14a2eb3b149b96a0f6dd93de417d95f3cfcb39a69306d29324d6f48b6c5caadb3f64146a8e71d4d498be0d178b1da9b526b53f413a55ee243c0e1e38e70f78f7c340a32485594e5d567f959c9a10ec48c9ed8e204057f7a83d75661c9c17a809e6398eef7a0cd06a9219500e40b3e55bfae300bd0547e567fd3bd9ebf6b7e1466f7fb2444252eef0026708ec809b6894cfcb8f3e4be2f83a9ab50fdcee035b864730982f5884b6456ed23de584230180a397d6e25a0aefdc3c1ec6f44432fa7d3a2a94f301818b42034f1bc8d4c4963b1e8276cf7b5629c16422aa00d4f6427ed0e4bff5168425459986c70d4e0f162f2d2eba8286182fb6d9bcd0a5bd6b30bbaf92f21d00726a2c0dc2eeff3f1f9a0d22585cdff401e41859316f9ed92a527d805d6702d4e59c1d06469b1fbf6917d297f066fab8552ddad6fdd19cf3" }, { "category": "catalog_contract", "entrypoint": "bundle", "expected_failure": "transition_chain", "id": "bundle-invalid-transition", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a31766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf9a34bf617c5b17787b697f1fae9b138052ef692aae68697529ca475bf98f5057be124acb4b0e8ff262d8e30ccbbd1390bcc6d02cf2b3732088fdbe8c9d93b2eae93150071d339bce7bff8b597daa5b32c64db8b6506f6912a7964521ad70fd0e4af8214a40164ad70e1ae8d2af0ae036548e74d62ba5af0fa97263c307d14e0d24650b06abb99b75e64456cbffd5de88e044a55c1f9fdb6cdee38bc0ad5e3ace73bfbd8bb81ea858c68e5b97f665b1e286efad1de4868ee6bbeb9ba04affed93cdb8c3f67dd1a5929e691b6ec72a369487d396e6557213857a067f3fb21d9a79baa49c86b93747ebe43af390bac5143d52f90291a7662e8b9e1edbda2152b9aaa14ba932647787676ec939757ad79feea4f3183bd3f16fffb8cf559af18b5eb370af72861021c58904a985f1ad257530aa6c0441b463e67db87690370914503d6215027ad78140b09cbb49308ef6cf1cce4589e2d1adc72cfc1b405418be738bb1c0089cbecbba4f20fb69d93d05032c7674360eea59df88310d2aff69cc136d6d160874c1c380909002b4b093c5654b0502b9592e89d49397505ab2847ba5085c09aff02fccd5fa915f7c953f5c777b793168c0db6d1864373a670561a1821c09d920b94d85f9bd2f9e91670848f522c1aeb9f747350eb12397148ecaea93eda009c8b86dedbadd99bf0a3cba87b836cb14b0bcdeaa83dbecbbe20cf1b3c44619d7a484d56b61b43b6eb0273293deab180168b1436ef901761dfecfe82b65376ec5eb7efc9f089652eb27e128cc726a2a5cf6b2be28fa59e34c9d73e4db0cddf2d8104eb91873d037a0e470867da49ef8e31a50c4f63c12891b3391f0d485f83fb7869b7e24801d274cc3a6776756d67df612b731eafd00b008f35322aa623bec94b043c66164f014567333bf6e72e80a86ae4afb0ad860fcf44dfb90ae4e91bb7a96ddacf24d34f0d3df25087c309ea37d9646d364c2415ce218935afc49ea81121ca47d4e99645129507ac82e5bc56dbef22a81eb6583200d8eacea029273fcf08436d68636641a3259d6645598fdc406b22422398534d098baf5620661cc2af5d89ec8068ef42b49040b7f10b04e18c8bcc730183cd4467f6b0e6e8005c4723a1c49fe22803eb056eb1483b7046d0f1c0f4b89a10adffe5f9bc76aa48d41dcdb9b98c861d17fc169056c978e1495fa39439bc9a63b6931d9a756c025d0eb3923a05c215528c110a1b171b8bf2dd422f3a60960e6b6c06f53ff34342e0e55c0b5df331f0dd734dfe3ae7a89b355a79c543ec970d2892738ea8b00dba120da689b52f69bb0dbc25f9e3b06f760b618f686bfc780762534984a0a4b32c174027dcedf32482f2634ea1b0015aad39435a1d0ac66c9bd6c586eaa70c66d3e7c59fb7778d73a9769873f4cff74d2111678d9f878c1fe858e764d6a88dbd924b47af4ec4ae66764b719e7fdb8636928692030b748873e15c443ae63cc47023b541ba20462bf97c8efefd1a49532fbedb04e85a4fc7ff094ffe6868f72147f9f3de80bdc527fe68010cc13fc414ff1d145cdb8668c184abb2ac4cfaa76a33e4bc8045f557139579435d40351b9e795b73083ecab686d1e6e1fb62e803eb96ce5129de5a022ebe9c86f03d6653b71370033de404fa2c9c6aaa20e520cabd8943f1b73ba6aa06fba48fe97c3bfd612d86ca9587105b56b2efa9f59b1e1346febafdcf01c27c496f8db8a8dbc093ad41ab53605f5c3fb072baa565c1a7ab47bb3dc5d7f4a811e2a7749d5f99ff21102a53bc1f2a812fd4a0d7f35e78c6dcf7bae137908a81983bd73cbda9799e14aab1e5c1eaf682dda0a1c3d2d31d0c0f258b24e68bd3e422078e66baf769741dcb166c52959bbaf7c26e626e9210b5801464e206fb3ea49d9bd14dcd12263f17a35e49d1324c5c849436ca0686b816f9eb08e9145307921226da8b5d8f1bdc3948c43d33b9f9dfdbd2a748e15abb25c203e308c34ef220ff39f38859534b173074bcd08200705896ed14f1e04399b8df7b37350dd1a6dfdc556776a334d8a0f6583a4e17e794bb90bdba247691adb2ab2b2841ae21f729b867d65bc70be004594dcfe895129a03a89d8b402f86c2af28c1b2c01225c97633591abfa1a13c9e5a9790ed4ce9c894fca11917cbe84bc88558c8fe6c4026a678270cdc8366f14642f2fb12c6d567cc1a76f3b14898e5c08d0073a83775ea2ecaa8bd409afd3cb249a48e0d7bdcd19d737befd02977c58f4d7d8612c0ae4b770c42ff8351581259c9d10cebed08e1f4b50262a507c7d8013d02f1012e8e901eb9e61b58afcb77eda9a870c960007e5e4eb8ddb8df64ab226f80af2c484dd6af42f6abaec91f8a6aca44637621165c4977d90af0425be2d8bc27361e4dcae4160b5c791592fc93958b55691a650a74eb50f83a968ecccdc1272530a8a8a58274f4ca8c2f330f67bc2771863e2ef3701f54e91b63a97c66da07f623efb84c22ec48d80868b51a29eead6d1404e011c7553f29d3a17df8bbd3e5363ddae956024efadd6efa882217596bdba39fccfd3a52bc959d3559c313eac75678999f25916180588393ccc9571bd0dc16dd6d101c732db2edfba2f453e28529ed3916c4a833d7f04bef965b2871d8e41b42f147f81c492ab4c2661aca41c3729e51f749098239262d0e1dff477d08bfc5ca9bffc9213fe91ef5d520cb793c80c66a9269871f07d09a29a9f42c45c7593f494e37f32e1f476e4ac30973b483d0b963807fab069331e1bf8d0f41cdbc5f9c36144ebc4d0caccfc5c74ba0635d32b636c0166e500d946929f40d6d2733ac3a3f9f62ad47bfac7d9353079f28e0ba962359b18bb850083a19bba587e29cd3641011e64ce5efe2ca570cb8c33fc864fcd89e313666b2822ce80fd37209f461a48bd951a14a6bdc23c54ae0e4dc3865c3ef03c8dd4ce2bc69cba197f2188e69661eeddf16d3421c65758a445c7524b29e71ebe9794c5b0f5b11fecc383c6384bcfa8cbe98d6f8ab89032a67749183fd22685dd027f5695bba1521199bff0778553c13067a8d07ca096a6737b355118a871cd770d52a0f019d6124c1771ab9c58c4d3201e237bce5941f78300a398babb9accfd8be104198bf3fd66fb0ffdf5f556ffe90cd9bc3667605312fd8303a975ccf6aa80abc6bd89f226756042c9a3edffc51bf670f80659a199227eb34b20c0163633e620ede21df5990d86299dd91afa2bac481a36fdd21c375b526b776e371ced42ad438a9e956287eecac5d72f18b9d54d55ecea3932cccd65e9e3a4e2fba11e28d1218cf85098c28a964c4482683baa455faa0d4f2cf45fb465ad16b890d8f78d32fd7beccfc6a22eb0cb90e4191f5ca7262248324240ee34741a49311dc27ebe0032ae8e85c5b4c6a8c0d9888e323470db90e85dcc14b4df4370021dbf23880125833d9615ea8003e23f5bcb3b2522c5ea9df5ad845a440d661d18a7e986a71be96847253bab96852649a739c39ced08a6cf4b1c2b32addfc5d4041c10b923faf5e0086a397c157eadf53667a4e2f7d0aa73776a6727aa49a300e053bb75f6911adf3108207d50da669923e3946ea78e48b892053984042183ee2ceb073982708a29f5b55fe76bb29bc2ee2ce31973d840d24cddd928fc6d04baa22380161a9ed7404cedd52d2a0550be5b2441b5b2cc21dbee4f475fc8417faa322b688492e9392c28969551b9194f592ed62730ff8b221645f9d71f8803bba5472f8a5b917f081d7be1e26b1edbaa365f6af8b17cfd9d90f8fe84aec85aca0135cbce79f8767f833e460956a5b60ee42ecc812ff00143bdf6a89611bd882c819a05dc356a6fbe310527aad28c103d97ec800ef3f2b34a88ceb6c68c9d00e9005c0421533badb8354eee36a8842743e8224e3c84d95406d39a80236bed966a4d981727a5d698cc1f80f367f231b8b18e7c08b8a7a50c3cc176c56c40ca2b2ee2d7ce72b2626b9d5087129ac6ce14901b57d42d5564db75270dd894f51e1c884564c5829b2e5dcd8079c6024aaf95a274790f0fb5604706e88f074840bd1d2d19aa6616e7ae34e1fc9490d9803542ad2576a061f13996f7baf520605add88161315096ba2a6eace763290c49b4188b1220566fccf8759bad85dfa8cd9c53dd3d8863dd57b38218b08ddbca164f44fe1ebacad09faf651e36fdcc987fcd1ed30f9290f636d1ce0de334be8af15dcbdaa89a01d4ce7ebd5860c24127c3c955a1a8f3c9e538d618c138aead14eabcef86df0af2249d6d907774e7e7c056d" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a31766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf9a34bf617c5b17787b697f1fae9b138052ef692aae68697529ca475bf98f5057be124acb4b0e8ff262d8e30ccbbd1390bcc6d02cf2b3732088fdbe8c9d93b2eae93150071d339bce7bff8b597daa5b32c64db8b6506f6912a7964521ad70fd0e4af8214a40164ad70e1ae8d2af0ae036548e74d62ba5af0fa97263c307d14e0d24650b06abb99b75e64456cbffd5de88e044a55c1f9fdb6cdee38bc0ad5e3ace73bfbd8bb81ea858c68e5b97f665b1e286efad1de4868ee6bbeb9ba04affed93cdb8c3f67dd1a5929e691b6ec72a369487d396e6557213857a067f3fb21d9a79baa49c86b93747ebe43af390bac5143d52f90291a7662e8b9e1edbda2152b9aaa14ba932647787676ec939757ad79feea4f3183bd3f16fffb8cf559af18b5eb370af72861021c58904a985f1ad257530aa6c0441b463e67db87690370914503d6215027ad78140b09cbb49308ef6cf1cce4589e2d1adc72cfc1b405418be738bb1c0089cbecbba4f20fb69d93d05032c7674360eea59df88310d2aff69cc136d6d160874c1c380909002b4b093c5654b0502b9592e89d49397505ab2847ba5085c09aff02fccd5fa915f7c953f5c777b793168c0db6d1864373a670561a1821c09d920b94d85f9bd2f9e91670848f522c1aeb9f747350eb12397148ecaea93eda009c8b86dedbadd99bf0a3cba87b836cb14b0bcdeaa83dbecbbe20cf1b3c44619d7a484d56b61b43b6eb0273293deab180168b1436ef901761dfecfe82b65376ec5eb7efc9f089652eb27e128cc726a2a5cf6b2be28fa59e34c9d73e4db0cddf2d8104eb91873d037a0e470867da49ef8e31a50c4f63c12891b3391f0d485f83fb7869b7e24801d274cc3a6776756d67df612b731eafd00b008f35322aa623bec94b043c66164f014567333bf6e72e80a86ae4afb0ad860fcf44dfb90ae4e91bb7a96ddacf24d34f0d3df25087c309ea37d9646d364c2415ce218935afc49ea81121ca47d4e99645129507ac82e5bc56dbef22a81eb6583200d8eacea029273fcf08436d68636641a3259d6645598fdc406b22422398534d098baf5620661cc2af5d89ec8068ef42b49040b7f10b04e18c8bcc730183cd4467f6b0e6e8005c4723a1c49fe22803eb056eb1483b7046d0f1c0f4b89a10adffe5f9bc76aa48d41dcdb9b98c861d17fc169056c978e1495fa39439bc9a63b6931d9a756c025d0eb3923a05c215528c110a1b171b8bf2dd422f3a60960e6b6c06f53ff34342e0e55c0b5df331f0dd734dfe3ae7a89b355a79c543ec970d2892738ea8b00dba120da689b52f69bb0dbc25f9e3b06f760b618f686bfc780762534984a0a4b32c174027dcedf32482f2634ea1b0015aad39435a1d0ac66c9bd6c586eaa70c66d3e7c59fb7778d73a9769873f4cff74d2111678d9f878c1fe858e764d6a88dbd924b47af4ec4ae66764b719e7fdb8636928692030b748873e15c443ae63cc47023b541ba20462bf97c8efefd1a49532fbedb04e85a4fc7ff094ffe6868f72147f9f3de80bdc527fe68010cc13fc414ff1d145cdb8668c184abb2ac4cfaa76a33e4bc8045f557139579435d40351b9e795b73083ecab686d1e6e1fb62e803eb96ce5129de5a022ebe9c86f03d6653b71370033de404fa2c9c6aaa20e520cabd8943f1b73ba6aa06fba48fe97c3bfd612d86ca9587105b56b2efa9f59b1e1346febafdcf01c27c496f8db8a8dbc093ad41ab53605f5c3fb072baa565c1a7ab47bb3dc5d7f4a811e2a7749d5f99ff21102a53bc1f2a812fd4a0d7f35e78c6dcf7bae137908a81983bd73cbda9799e14aab1e5c1eaf682dda0a1c3d2d31d0c0f258b24e68bd3e422078e66baf769741dcb166c52959bbaf7c26e626e9210b5801464e206fb3ea49d9bd14dcd12263f17a35e49d1324c5c849436ca0686b816f9eb08e9145307921226da8b5d8f1bdc3948c43d33b9f9dfdbd2a748e15abb25c203e308c34ef220ff39f38859534b173074bcd08200705896ed14f1e04399b8df7b37350dd1a6dfdc556776a334d8a0f6583a4e17e794bb90bdba247691adb2ab2b2841ae21f729b867d65bc70be004594dcfe895129a03a89d8b402f86c2af28c1b2c01225c97633591abfa1a13c9e5a9790ed4ce9c894fca11917cbe84bc88558c8fe6c4026a678270cdc8366f14642f2fb12c6d567cc1a76f3b14898e5c08d0073a83775ea2ecaa8bd409afd3cb249a48e0d7bdcd19d737befd02977c58f4d7d8612c0ae4b770c42ff8351581259c9d10cebed08e1f4b50262a507c7d8013d02f1012e8e901eb9e61b58afcb77eda9a870c960007e5e4eb8ddb8df64ab226f80af2c484dd6af42f6abaec91f8a6aca44637621165c4977d90af0425be2d8bc27361e4dcae4160b5c791592fc93958b55691a650a74eb50f83a968ecccdc1272530a8a8a58274f4ca8c2f330f67bc2771863e2ef3701f54e91b63a97c66da07f623efb84c22ec48d80868b51a29eead6d1404e011c7553f29d3a17df8bbd3e5363ddae956024efadd6efa882217596bdba39fccfd3a52bc959d3559c313eac75678999f25916180588393ccc9571bd0dc16dd6d101c732db2edfba2f453e28529ed3916c4a833d7f04bef965b2871d8e41b42f147f81c492ab4c2661aca41c3729e51f749098239262d0e1dff477d08bfc5ca9bffc9213fe91ef5d520cb793c80c66a9269871f07d09a29a9f42c45c7593f494e37f32e1f476e4ac30973b483d0b963807fab06b270506f4da9112d593ffd06e59f1d9cdcccde10d6cdb266c973f7e6e4e7efd6bdd5b8d8246c7d31534cdf3eef238c87ab6dfc1543c78fa8105bb65268e05bd84118fbb95b3576b28ca215b080d73d551f120a416c58265e182478a91a24a26662839e5aceb212085401b798b985c0cbefdcf3c4ff7555ec5914723a032839bcc2ac787a118782bb4cd8b65e6e7c0623b20da0640bc56c6525927e94ba89a94f69be8b517d6e982d10479cbadcbec973e92a08333a665164931843dc8c51a1e5a94b3ae5c0990ace76c8a5ccd3101f7812bf9d2b26e797a4900c97dcc7f025cb8f90ac10943187daafa56ca83371a3167cf1559efe20ca285acb080c7f09cf21b1599fbed79ee04f6f49358f1bf11ddb4717f210a52f083188259dae0aa9aadc78cbfff606d7802c4a3f6a69e08f067e31b09af976d7cb24a3bdb173139da0bf0e615cfa8028928818802e23bae480838f3870a3a5b61406a6a3f26c743ba7fb89a906282f49bddcf3f1ab9c71204e2bc227fd4d504e5e2b9f4f0bb1527c42a86f55791a7d5c71b1dc67c26a80a47b26c432de949bf7fa619ad9bd2fa8a3b9875e29fd1a63caccadcfc017e50a63b344328495eec34720f4d201ca470b0562faa86c2ddf59fe8cdd993a51e2266db8aee5cfd22b9995b684210bf38d15a36852a8d08ba8e0dad3d5aca203e23c2e0a1deb080527e3c691219bbb89ebf0aea68552b35fe8088267a8c24c794d722a7d8772e2f37addadf851c0401ba23e8aab1063c22310d66cef73661b2e2fbdcbc62766f1578fb1efa44f44eb93aee8979dd310e367d5cd6708822e6e660a9de5fbc801938c81c39e0ec2ced11398e7f883ef6bf5ae964ae90cdff3ee30f72be4edc1ac0dd20bb7545e3e22e800d31b2c1564cf7d32c1b335df8433c01b8b2d77880fd495044d51171a47d29698589f2382b22aabe4cbd11756821d9262cae933a0041f9c5118656ada8471ca146957732377af6de591adeaa33453be0a96af99d82a7af8af8d317d21945dcca7ce50d20d2691f4d42b8b441fc36bc962bf51c38b3f8f88115af942dcd821dae247969a33b0f3ebbdd961b3286f88618f294bd44ded3b2cecb8541b04051043a7817bbae3554e83799b22a05f03a0e3184c20d5dc09c973876bd9864029a1626becd99cb15bcd87af6398280817309a4f6bd142bc476d7624e9f3d23e2e4e56fb66a51b751902ea8c2cb14950106cc354261db67785cd6c2ee5304903563de829c2954a8df28e00c0ea88aa33922493884717d27d4a750030a900b291bb06d5abee9449e8ff1fbf6e9537894165a9746c70284df9df91c5a7ea0c95d0b100d7da2a6b3dd246dcf80e61ecfa43e1e64f3e4cc07ba921eea97dfd275d5cec83bdf7a7f6cdb06daffac65f545e4eddeaf51b5bf2cfd7cddc281bd8ebd21f3621d222d57f2d12b78c1be0ec2b19a92f64431bda095c8153b513278c818479d24c11ec171915ed9aca37283d4cfb39ad73c07b02f4eaa5d93dbbeb402d0f7dbbd14a136b2827de49022ba3b7b8fde34a5f204f4737ac66c3f6da587e05bb38360bdc979fde5af9c6dfa7e66642747bbf3b13efb8fe8323290fa0fc9d6189c9063df111eed3fa6c799672935ae19ce30f914f463d652ce720fb9ac8a28c8c499b80c622d7e2a4c8a0d31960089f11e80168765b27d223edbd88026e2859410357a29400f74c1e508f2e05e4359311ca1c4bdcf9fb39812cd3d83f522e93551f2af2d1082" }, { "category": "catalog_contract", "entrypoint": "bundle", "expected_failure": "boot_session_bound", "id": "bundle-long-boot-session", - "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a32766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf4947bb005d76dda2b01346557b2a817cb2e61d3ee41e1ed40a60675eb448ebc692122e936fb96c0fc6f3d98d1d49c3fa3183100f04addd073a165e8a326e72006d1af6132f04d17845d0e192273d923c38bbb07d36f9a088b8b6ad8ad4ea4faf552020b954f33ecb265e0a868a56945c46b3399420d6ed1fdb06d4f252c45d5416b6774c2f294f8a16607a8d0b0c3484e691c5900f03509f8e4b57edab3d777544ea9c77b542b55678b26cd0c889168758dfb0bca3812973b36d9e8288ae73434271d1e6327fa526e2768953666ef37dca7d3a8de0a1573a3355e2973987bd3b99916c4dd697bc5cf4de31e5b8fb7033909e202c07ebedf9fc316865df29e4bec9ba5499e4ba720badb0a01455b4c5fbf352b0e1581da0df10bb1536a60b814525cad9797b8155bf5366df765d78beabbe1523919fb05dd2e239cb5f5c5a28cc454193231695419af148fd66485ad9f77716ed17f7bc8630b992004b87dc5e24ccadd5d653365293a1520c809de65dcde81675a8b1e45d439ac7d5084701ebccd6ba997be02726f472e5fe473f205bbf155069395cc3cf0de775542e5a34774080df88443b4cf2e221d4cb1c68442afdbde13006ec03295b3ea6188ba6a7b87e80f7b04799a35c0733d30f57ea74882a665f309ba2cfe8bf78e0bbd247bfadc265fef4e56a54e5959815bf9425c19ca86c4e608993c1a1748fb0003b73bd3d994ae1beb954f2ae6bbd1652fba6b2752f6bfca26f2a81e16be37b7e695a01a82dba91f9b17543661afb06da489c238f799c3d1370103be72349e7b62fc10aa0d0b83538d326f5e023c381cc5c2dd09c993e0f92368e63cd913829a64f512e02236df284a861208e63139177241e56ffcbd1d9d8dfc629990bdde0286e6a050450c3154e07f6e786a63251108e6b38be6e1a10a29025cc05a62bd345278985caec3b509158b5c8b85b3ef3cf833cda1eb7204305df8e78c8b66675e6247b57d5d491f48d0fe9ebf2cb3a8123ab0617d38d119e405c86a00d8bd1a51b94d0abc5bb8512a1a05bed83113e4963593fd3b941a5197f5dd232e178b6459ca732b7c55de5ad50a1266a7bdf51f93971bd9bf1d2c9cb5e3538fb1cfb448dcbd07a77765f7f16d78ed3801b338dd52b8e3b554557a86c3a659dc11f5157c879f50c27d5ce25045140f605eebced91306be9b48dea399af7c6b452ffaed4d542c9864871fa2d87c4bbdab39429348229d33152037d92cbcddb4382aaac4b65cab559c595f1ab04e316b460babbd539ef05c8ec54c121f114bd3a185bd276ad9c18deabe6da28f55aa2c9b170dc72229fb0ca9804752dba0946b7a55e82b0f38b57c5cc199fdd73ca33c35c2bf9f7184e4703b52c0ea20fdfdb66e90985db80d95733e40509dda16f794b18881b1ebd18fd5d06c3978289bc08f266b72ed84fbd6ceb95ef61dcd0d35e0d22a221ce87f36a096b7c5bc10af7a036505ba45c2d3e047bcbe06d04e818b72c01617fc661434b3539caf73b2697e4ff520848add9e417b7355d9be9c9d53bd8cae401c166301028344b78feb247cc03807d731eadd712f31cf805680be139cd1538845f94205e7578c18c66f92327560cb60e6840ad36a3b025e38b89d2c9790c8cf475af83fa163d6736db6b88a1c4c0e02b8bd30e76276829bb53872b638ea1484087c9f24e501fd3ff824fbc13b285dd467f67a56ed5c41dabc900a9362597313dca227f3b43d2927514dd2b65d81693bc1ad7c11a7ab5e1903d2a66b18e751455f06592cd67675bc6d4c8a5a85fb737ad616c43248e50ccade91a25ac6e09bc4130d6504b17eeb5678e6902428ab0c0fa45d18cbffba9fbbc69f5ec502c4ac038ff95ff96c2bb2ef1b409f3a8b40b8e173a8fe47cfd8d19f62cf6633751df5797278b2ffd60f962ab9712d54f9e696e7b2d44e92a441006b602f6ff40641bc30c0cd69944fe8ce79dd997535689d9718a4381c91eee6a7289a3adac0938e65392da820a7d3b1ca344893bb994a7eefaf2976d8bb0068c496a8fc3c4fc7540c56b53ec628711fdf21cf015f0d61fc879e8d7558f7beca81575ae18a80e4de65c073e9bccad60d73f18bb4b0e5962ca740a1fe14b38ba7302fb2d6f5f53063a9c69e5889eef92ea3c19f1e29a2dc8f91124c4b474611d5d3b52ddcd745efd92b291d04e37eecc8f378c0822277e346364ef529e4047e2f968c4406e6eb19c7eec176affbc3e58fb160d042580448902c5e341bc50cb4f5c83a176c8f08349bbd10fb4129e1cba0cc7eaf284cc15e648639c2a72a298f4e02f6196cb0b47683881b30bb3368b9151808b4de1110fd69a809288f4c24eb4af7880287899a526f811c7925c4b4599764f7a3126541770d7faa308f9d9b181fcff76e0b8da516a4dd7787396d26e9b8b5748bd21027578586f8ea2c6ce037d5d1bc1a65bf530947bf6864ed261583f1b8789e036b4f979c131b74fb81a4df532700b53313690ed79e6aa70b2736ce78959afbb978379fe56cc8e9c201a4da20c1d8a20518a07b8df2458ab7004f79a461f6a7cd2af17b7eaee3df33ab3e9aab1ed0c5de10ed3fa37a00dd9bb5b1001df3884702bb669c68d163b6bca63f59b787da52b6dc86ab3dad1f96e76e701074f94f0c049b4d65809e4a7628fd3417d4b378a77d490c9f79b656643c870290490923ba708514abed51ad972c2421fd6b8f2916809bb3d434e6681fd46c5712fced4462fc51dcc75ab614a6d79bd4ef8295c33306b683877b7fd8f3e12a5fbfac3f6f942784a9aaf7f56912d5999eb59fc10ab5c5f2356220b6d808ab6236b1b47356aeb5bdf2c819d9eb264b5252978038690d09d3d70f891dae1bb4449eb540c276c952c316534ab51032e46b624a8f30536d3d1d38449e5abb2259483f7b9bfbc3700190b1a39609e2f87e9fcc27ec38ff6b736d4f0f155dfb91cd1fad765ec4fa776e342a361ccc3a0d65471217c9fc496fee0d70847cc875ffe3621bc944af0cbb4a8ac6586c7df5484591a418033f69882f1472fa8d4f03a5b1acca7e06476a43852f183ffbf5911f982938d06186a50bce1d37f9ead59972057b2b3d4a644c04358952b21134f2f23a86e101fbd7c044b855e400a791413668b67ac6fb93ce2b4f9f389e77dcc0a2a65b027300cc8653652f72112ba2c59bc565cdd2cfbc9073ebc8620a8a1c8b5ad74a2437b6edec440abb0f66117da3b32e63e0b7815bb7356b76c4689ca156c3fa994579d47f73fb7bfd904f4f6bd04e089f214192b7c59b5fd091b76d84c607274bb3704ddf12e73ed922dd341d1669b7af843f77c3143636e4cebb12ea9c847e95ffb46eeab4b8432a5275b78e7e771b0fa3dbd98eae0629f1142085d048d56e209a2fd125c99eedd87fd0ee32b458e5b2b1c774005e88de7db98ca8d255ee6f335502fe4757a2f47f2b80a71f7eecba7535f4d7f973e688591ebdee7c47efaea5b8e36764c0209ca6c7af10fb667e21acb6cad69ef62a2ecfd102032d3c80ff108597426c73f9be8d0352a421d37cd49ac076cb28d1f8f2bbfd194be96cda4a9b21181dbc10b5dcdccce60d97f6039c82308c0a4828626ee47fc67e1b865077109cae538fb83e29567b863f7ac8ae94f76856be8792fe12789b816c08bca3c9a899e18cd7d475b9d14172b8e327dd78360c3b1db30d2111a5d7d8c5b2627b7565996b0b413d4e9f90fbb3b40ba54d02831dbc1d466142a2410e340a177224943d370be97cf514ae1df169717257e599d439896bff888ee8327d1b1ae7c2826b4259496ff98a902b0167d51e4b5e522e61b0b4680512a05a543e91a8685c9e62783943d68f50d018a38a80c052ce7e9141e28d6f7e246c31c33af81ff7ef8afb6f093a5aab68fc03593872786e8078f171865f3f95c1285bb1315ae0dd18fed4d94e93e07e98e7c42f18d815060cf4c5923f8d2d14e5eef8c1759b490297fc68d8149df992101cd36650a500837b15390af06c8871f5fd000a8e50457b0015887fae7728887ab7718c69a90be22e4b75709c4fc653cbc85ed6d781ef69f6d6dadeb609195dbe9d57bf2130a339e7b81463448e57f9025026efa66643d1dc887e54fc6a5df32ce7fcd243077d5052b5282d14fb226fc74308ac84d9e915f48d681e2a773daeaf6a9f8143f8470259f269c7f0304a7de86ec1120d2d94731b8fb050ab08941faab1d1429e08d2f0df15fa25d8a4535fdc4001296fdfaa03d6beb1deacaf06b2e6785e871189321fe3b4279873fbb3b6430287e18f3bb1d14becfc847f3c3eabb2e87ee14e931792f6b0fb8da45f67ec7de42fd28564e85db908653851ce4fc9415dc207bba2652634c5f7fcc89d454cfe19bfe17b32d97eb179fb14451d517bfbe3aa877f0f03d2cddd93c618f54320033721c5b7ad663819ae8e5b88ec96f7132176d9ae4d632c0eacf0bfb91" + "input_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a32766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf4947bb005d76dda2b01346557b2a817cb2e61d3ee41e1ed40a60675eb448ebc692122e936fb96c0fc6f3d98d1d49c3fa3183100f04addd073a165e8a326e72006d1af6132f04d17845d0e192273d923c38bbb07d36f9a088b8b6ad8ad4ea4faf552020b954f33ecb265e0a868a56945c46b3399420d6ed1fdb06d4f252c45d5416b6774c2f294f8a16607a8d0b0c3484e691c5900f03509f8e4b57edab3d777544ea9c77b542b55678b26cd0c889168758dfb0bca3812973b36d9e8288ae73434271d1e6327fa526e2768953666ef37dca7d3a8de0a1573a3355e2973987bd3b99916c4dd697bc5cf4de31e5b8fb7033909e202c07ebedf9fc316865df29e4bec9ba5499e4ba720badb0a01455b4c5fbf352b0e1581da0df10bb1536a60b814525cad9797b8155bf5366df765d78beabbe1523919fb05dd2e239cb5f5c5a28cc454193231695419af148fd66485ad9f77716ed17f7bc8630b992004b87dc5e24ccadd5d653365293a1520c809de65dcde81675a8b1e45d439ac7d5084701ebccd6ba997be02726f472e5fe473f205bbf155069395cc3cf0de775542e5a34774080df88443b4cf2e221d4cb1c68442afdbde13006ec03295b3ea6188ba6a7b87e80f7b04799a35c0733d30f57ea74882a665f309ba2cfe8bf78e0bbd247bfadc265fef4e56a54e5959815bf9425c19ca86c4e608993c1a1748fb0003b73bd3d994ae1beb954f2ae6bbd1652fba6b2752f6bfca26f2a81e16be37b7e695a01a82dba91f9b17543661afb06da489c238f799c3d1370103be72349e7b62fc10aa0d0b83538d326f5e023c381cc5c2dd09c993e0f92368e63cd913829a64f512e02236df284a861208e63139177241e56ffcbd1d9d8dfc629990bdde0286e6a050450c3154e07f6e786a63251108e6b38be6e1a10a29025cc05a62bd345278985caec3b509158b5c8b85b3ef3cf833cda1eb7204305df8e78c8b66675e6247b57d5d491f48d0fe9ebf2cb3a8123ab0617d38d119e405c86a00d8bd1a51b94d0abc5bb8512a1a05bed83113e4963593fd3b941a5197f5dd232e178b6459ca732b7c55de5ad50a1266a7bdf51f93971bd9bf1d2c9cb5e3538fb1cfb448dcbd07a77765f7f16d78ed3801b338dd52b8e3b554557a86c3a659dc11f5157c879f50c27d5ce25045140f605eebced91306be9b48dea399af7c6b452ffaed4d542c9864871fa2d87c4bbdab39429348229d33152037d92cbcddb4382aaac4b65cab559c595f1ab04e316b460babbd539ef05c8ec54c121f114bd3a185bd276ad9c18deabe6da28f55aa2c9b170dc72229fb0ca9804752dba0946b7a55e82b0f38b57c5cc199fdd73ca33c35c2bf9f7184e4703b52c0ea20fdfdb66e90985db80d95733e40509dda16f794b18881b1ebd18fd5d06c3978289bc08f266b72ed84fbd6ceb95ef61dcd0d35e0d22a221ce87f36a096b7c5bc10af7a036505ba45c2d3e047bcbe06d04e818b72c01617fc661434b3539caf73b2697e4ff520848add9e417b7355d9be9c9d53bd8cae401c166301028344b78feb247cc03807d731eadd712f31cf805680be139cd1538845f94205e7578c18c66f92327560cb60e6840ad36a3b025e38b89d2c9790c8cf475af83fa163d6736db6b88a1c4c0e02b8bd30e76276829bb53872b638ea1484087c9f24e501fd3ff824fbc13b285dd467f67a56ed5c41dabc900a9362597313dca227f3b43d2927514dd2b65d81693bc1ad7c11a7ab5e1903d2a66b18e751455f06592cd67675bc6d4c8a5a85fb737ad616c43248e50ccade91a25ac6e09bc4130d6504b17eeb5678e6902428ab0c0fa45d18cbffba9fbbc69f5ec502c4ac038ff95ff96c2bb2ef1b409f3a8b40b8e173a8fe47cfd8d19f62cf6633751df5797278b2ffd60f962ab9712d54f9e696e7b2d44e92a441006b602f6ff40641bc30c0cd69944fe8ce79dd997535689d9718a4381c91eee6a7289a3adac0938e65392da820a7d3b1ca344893bb994a7eefaf2976d8bb0068c496a8fc3c4fc7540c56b53ec628711fdf21cf015f0d61fc879e8d7558f7beca81575ae18a80e4de65c073e9bccad60d73f18bb4b0e5962ca740a1fe14b38ba7302fb2d6f5f53063a9c69e5889eef92ea3c19f1e29a2dc8f91124c4b474611d5d3b52ddcd745efd92b291d04e37eecc8f378c0822277e346364ef529e4047e2f968c4406e6eb19c7eec176affbc3e58fb160d042580448902c5e341bc50cb4f5c83a176c8f08349bbd10fb4129e1cba0cc7eaf284cc15e648639c2a72a298f4e02f6196cb0b47683881b30bb3368b9151808b4de1110fd69a809288f4c24eb4af7880287899a526f811c7925c4b4599764f7a3126541770d7faa308f9d9b181fcff76e0b8da516a4dd7787396d26e9b8b5748bd21027578586f8ea2c6ce037d5d1bc1a65bf530947bf6864ed261583f1b8789e036b4f979c131b74fb81a4df532700b53313690ed79e6aa70b2736ce78959afbb978379fe56cc8e9c201a4da20c1d8a20518a07b8df2458ab7004f79a461f6a7cd2af17b7eaee3df33ab3e9aab1ed0c5de10ed3fa37a00dd9bb5b1001df3884702bb669c68d163b6bca63f59b787da52b6dc86ab3dad1f96e76e701074f94f0c049b4d65809e4a7628fd3417d4b378a77d490c9f79b656643c870290490923ba708514abed51ad972c2421fd6b8f2916809bb3d434e6681fd46c5712fced4462fc51dcc75ab614a6d79bd4ef8295c33306b683877b7fd8f3e12a5fbfac3f6f942784a9aaf7f56912d5999eb59fc10ab5c5f2356220b6d808ab6236b1b47356aeb5bdf2c819d9eb264b5252978038690d09d3d70f891dae1bb4449eb540c276c952c316534ab51032e46b624a8f30536d3d1d38449e5abb2259483f7b9bfbc3700190b1a39609e2d93f2e1ce74a681f8e130c7ffec4fc2a41ad0e7867d8d6ffe32f75fa12ed4dbcbd2496e3c7a8ec5f0f0ee871f43de9b5eb22e39dd984bf8c4babaab7693dad9559555004f893ce39985e65d26bbc3eb355416cdc1ee6a20b93c5ab69bbee6191cf999b8a1100e6a4abae0e24993eb418f605ab2a88dfd57c65443887b2f1d002d22a9750b1eba763860985a4830481b1c679736b477aa3de2a6f7fddcf170cc390178b42f0a26c9720a60f32412bf3608a44e4fdc2ce9965630ea9d6db0b9a9b9ac7cad4d6367cdd35ba4bffa6071856a65bf7a1f6517f46b4ed6604781c51b7436ba834c9248fb3ed1b1d754e3f2af18e1c5ea0c78277d44a3f613107bcf5f7f7e66b3271bc8fa25728b9c23855cd56edc62b91ab771315848425afdb134afc976df52bd5ef6eb468429fc7c487ef0fc6ce0f433f29aebe17984104502612f9052ea3393f21d5d85bfc59fed0ce3394b800e3d1177732ef589efe1b2cb9a196ce2f6355535b56d623f45f2aa5520f9b8d0ea4b472c7396237e8e8be0d1f2cf74f7ed52eb69271b5b4dde7178be17ae06ee1bd67aa673e46db7e7f71d273bb6c601a11f5d663ac67383f0b1392b5f0b3cd742b2056cb4981b9e2ad9df9ae88bc9aceeaa50d89bcc0b46e6e0d876d9656638f9150586bc9ac62bee5ca53cf2be721c6c59c4eb77f982e38e7cb964fd90a1f44b7ebf5ae7762efd7691a007c38bd83294dc8815cd4e6c46991c2d018f2541e58765c3b4c161ca090b5e7d9e047729e14d148ea8d711d4efef0fb73756ab55d55a6e8a968d220037265ffb58c275224f55d37cb281de504f93d118c700216c459c0f808edcfa88e89527ddbeac6b2b2cb12b9b8af497b810b0007c37eabbb33fe2134c5ec1086a08a558c2369093c9fc2182a50b65b31519ca35a8175c77f4ef03053586f9ec09c11d32b49afe79f2939ded97ad908780cf348fd63f9efe038f05166ba6ef5112b6900e11a637fb8efa71a6ed3b07ec942d5ae99b855072901d5775e39fc9562ef8881244cfcf78289fc99554ddd63919bd21610f4d7339bf0387ab14d4865347c872b9f6024aba0b4196f5fd798480be641ed79bf6b02cb2aa530183e42465fc88ed765332e0896d77abea51a7989df1cd3bff13116ac56887512f55b5719e6a006ffb7d7f3c1ac2bbce52c2ade503c170cc38616fcd122e5290df41e730f17403a1d54996ab7549c1bdd0a376daefecf8e00c28817037c07792291849bfc6f6eb0a20d5de4e54e7aa2304f48e5efbe646521ed19c152ccd77f516c06823f9c61a19daa4bdd042188e76c5a396511754a9df562ea833d61c4e45bd1bc67a2f282d7a0ef3bb4407ec9097038e9cadae329cec1bc27a272f214ca8c143e136c1c80cfb225709c88b9e8110881de5f69113ae2223f56f2f7d5f5e74cac88b04deebd0ec56f36685e40fac98554acb1b8fa56cf10aaabb7d62c4c67f2fcb09705d216a431636c67eccc9ae9784a6cf4dded403ac91d4cddfcd679ec5ff275db18b1a0f4e5c5d9d83be3cc2c95a9800058c7f2d07ca21fa8558544e03ce2199288b3bfd6224832a65360ab098ea65f84266dc364de6f680193f40da38fb096ca7fd4bef0ee00e113cd5d306d668ebcb15860e41c4ac92d7abc8546062ed533c08011bb3572a3f66d21d22169034d6f83002dd72cc402a7aa3535898f8468ae3a7a7b969dfd54ec37a8e970ff496f74af990eb5cdb262ba2a0ddd44aa17191f7fd2a60a3815e1f" }, { "category": "hpke_context", @@ -377,35 +377,35 @@ "entrypoint": "receipt", "expected_failure": "noncanonical_json", "id": "receipt-leading-whitespace", - "input_hex": "207b2262756e646c655f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303939222c22627974655f636f756e74223a2233313931222c22636f6e66696775726174696f6e5f736861323536223a2266623264666561363338636136323130653764313562663132653962663363393130303964353463386135383164633361343737616363643732326262396337222c226576656e745f636f756e74223a2231222c2266697273745f73657175656e63655f6e756d626572223a2231222c226c6173745f73657175656e63655f6e756d626572223a2231222c22736861323536223a2262663664373938303332353535613339383037656532346430373836376639333535303363663836643066626537323638306162306638366231623630383937227d" + "input_hex": "207b2262756e646c655f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303939222c22627974655f636f756e74223a2233333539222c22636f6e66696775726174696f6e5f736861323536223a2266623264666561363338636136323130653764313562663132653962663363393130303964353463386135383164633361343737616363643732326262396337222c226576656e745f636f756e74223a2231222c2266697273745f73657175656e63655f6e756d626572223a2231222c226c6173745f73657175656e63655f6e756d626572223a2231222c22736861323536223a2238383862323839666332646239663930623831663238653538646231613830363633623436646266376534313637373333323438373531633630393531626236227d" }, { "category": "integral_bounds", "entrypoint": "receipt", "expected_failure": "noncanonical_decimal", "id": "receipt-leading-zero", - "input_hex": "7b2262756e646c655f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303939222c22627974655f636f756e74223a2233313931222c22636f6e66696775726174696f6e5f736861323536223a2266623264666561363338636136323130653764313562663132653962663363393130303964353463386135383164633361343737616363643732326262396337222c226576656e745f636f756e74223a2231222c2266697273745f73657175656e63655f6e756d626572223a223031222c226c6173745f73657175656e63655f6e756d626572223a2231222c22736861323536223a2262663664373938303332353535613339383037656532346430373836376639333535303363663836643066626537323638306162306638366231623630383937227d" + "input_hex": "7b2262756e646c655f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303939222c22627974655f636f756e74223a2233333539222c22636f6e66696775726174696f6e5f736861323536223a2266623264666561363338636136323130653764313562663132653962663363393130303964353463386135383164633361343737616363643732326262396337222c226576656e745f636f756e74223a2231222c2266697273745f73657175656e63655f6e756d626572223a223031222c226c6173745f73657175656e63655f6e756d626572223a2231222c22736861323536223a2238383862323839666332646239663930623831663238653538646231613830363633623436646266376534313637373333323438373531633630393531626236227d" }, { "category": "integral_bounds", "entrypoint": "receipt", "expected_failure": "wrong_type", "id": "receipt-numeric-count", - "input_hex": "7b2262756e646c655f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303939222c22627974655f636f756e74223a2233313931222c22636f6e66696775726174696f6e5f736861323536223a2266623264666561363338636136323130653764313562663132653962663363393130303964353463386135383164633361343737616363643732326262396337222c226576656e745f636f756e74223a312c2266697273745f73657175656e63655f6e756d626572223a2231222c226c6173745f73657175656e63655f6e756d626572223a2231222c22736861323536223a2262663664373938303332353535613339383037656532346430373836376639333535303363663836643066626537323638306162306638366231623630383937227d" + "input_hex": "7b2262756e646c655f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303939222c22627974655f636f756e74223a2233333539222c22636f6e66696775726174696f6e5f736861323536223a2266623264666561363338636136323130653764313562663132653962663363393130303964353463386135383164633361343737616363643732326262396337222c226576656e745f636f756e74223a312c2266697273745f73657175656e63655f6e756d626572223a2231222c226c6173745f73657175656e63655f6e756d626572223a2231222c22736861323536223a2238383862323839666332646239663930623831663238653538646231613830363633623436646266376534313637373333323438373531633630393531626236227d" }, { "category": "integral_bounds", "entrypoint": "receipt", "expected_failure": "int64_overflow", "id": "receipt-byte-count-overflow", - "input_hex": "7b2262756e646c655f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303939222c22627974655f636f756e74223a2239323233333732303336383534373735383038222c22636f6e66696775726174696f6e5f736861323536223a2266623264666561363338636136323130653764313562663132653962663363393130303964353463386135383164633361343737616363643732326262396337222c226576656e745f636f756e74223a2231222c2266697273745f73657175656e63655f6e756d626572223a2231222c226c6173745f73657175656e63655f6e756d626572223a2231222c22736861323536223a2262663664373938303332353535613339383037656532346430373836376639333535303363663836643066626537323638306162306638366231623630383937227d" + "input_hex": "7b2262756e646c655f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303939222c22627974655f636f756e74223a2239323233333732303336383534373735383038222c22636f6e66696775726174696f6e5f736861323536223a2266623264666561363338636136323130653764313562663132653962663363393130303964353463386135383164633361343737616363643732326262396337222c226576656e745f636f756e74223a2231222c2266697273745f73657175656e63655f6e756d626572223a2231222c226c6173745f73657175656e63655f6e756d626572223a2231222c22736861323536223a2238383862323839666332646239663930623831663238653538646231613830363633623436646266376534313637373333323438373531633630393531626236227d" }, { "category": "trailing_bytes", "entrypoint": "receipt", "expected_failure": "trailing_byte", "id": "receipt-trailing-byte", - "input_hex": "7b2262756e646c655f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303939222c22627974655f636f756e74223a2233313931222c22636f6e66696775726174696f6e5f736861323536223a2266623264666561363338636136323130653764313562663132653962663363393130303964353463386135383164633361343737616363643732326262396337222c226576656e745f636f756e74223a2231222c2266697273745f73657175656e63655f6e756d626572223a2231222c226c6173745f73657175656e63655f6e756d626572223a2231222c22736861323536223a2262663664373938303332353535613339383037656532346430373836376639333535303363663836643066626537323638306162306638366231623630383937227d00" + "input_hex": "7b2262756e646c655f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303939222c22627974655f636f756e74223a2233333539222c22636f6e66696775726174696f6e5f736861323536223a2266623264666561363338636136323130653764313562663132653962663363393130303964353463386135383164633361343737616363643732326262396337222c226576656e745f636f756e74223a2231222c2266697273745f73657175656e63655f6e756d626572223a2231222c226c6173745f73657175656e63655f6e756d626572223a2231222c22736861323536223a2238383862323839666332646239663930623831663238653538646231613830363633623436646266376534313637373333323438373531633630393531626236227d00" } ], "schema_version": 1, @@ -415,16 +415,16 @@ }, "bundle": { "bundle_id": "00000000-0000-4000-8000-000000000099", - "container_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1b766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfef94177bb023d9d7c5100833a3118c84c3622cffdfcf9ef269ab3301b3f743188f72cdcdec411e43f9e3b6ff4726081181a1862d419cec048c04a6740f6bc3dc40971f31f3d75eb2876381215eae53ba1bc3df6ff2732974b93e28dd4b3aebfefc9ef64b302a500b3ba6d9d2b1e80bd41e9e01083165b0ce61ae0568bfdd0636e8bb172c54541023f120fbd80918f5db8b1c22ba347f037bffc938708f0a186b1ff66d42027050f609b06bc367c43ab77b92b476c06dbe812f32f902dc3aa0adbda55006565875cc089e30dba2a649e73a6c91fb015d1127ca004c6678f0760ab4b5a6a37666f1b5dd32a91892153769d4be8a8ee8125ea1f840ebb2dd4ca01a9be433b1c5cf441f704f02e98e2ef48daa2353cb7c7c990b451d06a99eefe79231d13744f9ca044c7212de9e4d78af50805b48c2b6afac6ec497bc9d2f3204f4f2e9852d6ec0c4ecc82728eb02858ea3ec55e60b18c723b9407de281724bfa1760b7a93bf4cb067a4f2eaf16d7fe5b39be6d560b98bf8493bfcdc226eb3ce698af1d8fa7d32166b0627652086c7e01ae8f599341888cd4825ea9fde682d4f74d8fe9aa6b9d2a845eeacb30cde228bf8f018cd6d427c3b28f5748b046efd689542d9a4f6c84130458dbabc9df9fcf2b359ce5426062b1e7efbc37335e2b498163e0ba20fcacbcbd33bfbb25439bbc715f5df6b4643d47fdf479817f5d392cc3f39717ef3ef1fe2110ed5d7988782770ef08bad7625ae4208cfd3f5cd6cc04ba19ef48a7a11795b983ce0264a20ec850e11ff63a6cc589e09f7edf46c18fd72c545661ef9eac54028d9849bec0d88942636b37632ec22e70ea5aaa9566fc489a88a4b4c15138550018178e8900db4b7cff378db2fa43db0b8e9c405586736c26ad9566d36b12431474c383d191342a226182735a225ef7aea8e6f2b0d22d2ba92c6fe645341cdfb29b1d0961d073c1ac6233bdfb53ef8352c5d7285456046f2bac77ebac039c4f5dc6225815401291ea80e5250c361b156e78c570339f5048f62c5b69ec4d797d2114c02cf0123cb62ec92f480271587f0c64d98ef0e0ab0aa2d9c713bf7b74ab2e04d8a662921ff809c9b15b4f2a190462d7b4dda99fda98511ec260d1f22a2a69ef2406d06a0fc207c793ce9a479cc19335c746d48aa29022face0af22f158e2c4369d5a9747b43d12954bb2b82e8e042178b3c24c0b494a634c4b71d106693870a84fbaac03e40591582295bb94e256d7e075da00e78a4d2ff2b3dd9c6eb125c4493612f2c74070dd0487329cfb81244ab9bcb25646e40c3229f8e73a55429c8678d1b872ed674aac356c124d734f66e1cace3e5ecf8235a7734ba37c82c173ea43a2f92bc004e471a343be6fe7f404fb5a821365f32781bfb85347d25fd441d15e577d20901f53704195e8affc03368c0b3bd60aabe9ef14e854db1c9e6cd738d19ef1d20d506f7395fd096de963fee0ab7153be49322416426318aade21f20c02d7656d7bdc9aa54c7bc65e01ae7e0a92046c37385cf24bef7b860f2bfc18f28af6e0fb55c85f1c145a2aa1367daf479c326ff49b4e5f3e658057fe42af469aa29485c8a00cfc12da2d901058ffd2ed50915e76caa917b0e67e17fc5f29157694c30d798898af7f95a0ba07d91947c27199818283e8ce3a0adde1a4432c871e5322d863afd96b2159eb014ef98c6db40d6504355584d8fc47e249039cfffe1d1265ec037820a7a50613d4276caee2974e2e7b3d1651aea7855b0dbdd5555c29ca95f0bf919e6a5b0505148c5695193153aec27bdb02ce8da800a94ab0050fdebbc4c4ff50a169b9f262fca3cef3158bc844a9850ceed6ccee5f671bce70855a8efcee9a9543636c96cbbe6bd62a8b3a50259e8bf76118439f1eedc7d4e6c4aec08fcdd00184282b3d14624c9e0bf8f9c8b9b419bbd4bf3e02025f65b0828c94c4e2d77ca2e4f2781adac547b8fb4d9c75803ab55d72241945473b17dc819b4065f76469008d39825ebe36b9723014f72e528b9c1a083ddaba882a82decc88ff7c98c56e11ab722c79498ea1ce8ee53f367c23645eb78d0df19e6eb1803c8789455c72b2f2b087dc6b96c163f3cc17aec950b38ad776897c4af84afeadae1f0f07c5fc57d6fe7f0498e97c5843aab30da8d9bd8591d52252efd3ad13447cd210fed5985165714d871771a338e4cdf3afd9f8aab2abb7abb54b3096fed9ba818898fefd88db1973808a2c9aacda2515382375738cd3198624e45695fc3f9eb7b773c6373b8cd6cb749ea40bfa42911bdc366cf807a226a6d6020c3a4e838c6b0e1a30cff1c4215533430533d900c7aec49a172647246b02ca14fffdd9758a588423ea8a0c543b77edffb2147aa1801c4303abd780b21a3767341c78cd6ae526dd33a2bcce1c0cce433f08940df280da20a226da5e1f9dfc785b921e6e4c122f87d173999bf72c7a2b1afb925147dc823e9f0db2f3f234ee67d753d8ff2fbc54844fa1db423a45bda9b800680969902026de586b9684ecf6f117a38de98f2ccb4fc30028ab0464d9b781c95b10733f7c93ad61b0f848e8041ee4af5dc0724080836c65d015ba81777aa0ba484cb05e54ccb78bc32a8663a5379672e8ec9bd85d766b482ac661942a2f129f756f806577a09982fb15a39c75230ea1813f2b0f1e7c886100575521a230adbb3d466c020c8c943d7af51df009833d4cae36b2dee8fee49ce5bb892063a98aa9db5aadfc2014abcd69860c851a51afce466e09d0a111a80a331b62bcbd90ce44cda1535e7d8ce1dd3f6aec2b759edc4a258ddb87cc17f50214063f2b8976450aa2c8f07ef7b13152cc0760359a441ab31136554b8fc901ad9e08b312bfb180de3f9cf90e93b5d15a6be4f5482a528d194ce9be31274d185dfd9a8acc9866146393780d55af30f9ede8aa16093477991f1eeb9dab270c64b586496f017c5b80fb5c796faa2e5964d33ec8b61785a2c8b4b725d2033cbeca9a76a1183fd7456628e96cc4787a2c53b04519e5257d4be45d7142b52a733c19a6fee2eb36f67129d315521499a03943bb8d25d869c2f7beecc71102e31b9be1f5c5c999a172647d6003833b5a6f89f3d1ecfc66cf0dd78959b60eaf79adecbc0d7ff6da7cdd4edd85b75d2306445a3ab6dee5d08c1d2ce4a2be5944a962bc03aecb29edc15de96c16cedb810af7d4ca841f940c7a404f7ff5b8fd208ee9009952d87d783464e8611afd05a8767cb90ec3dcd1c697bdf5eb54776aafb7e29d1125642524a7312bec6ab442eea2ab5d9e4ad2cf98108a24a4fe9847611cc5b758470bd93d1af444e7d23f1d883b8b94944191993edf45eb862d37ff1819d87b1c2b8a3bd47dc589bc36d26a2ec2190b97970a29af2fa6d89edb90fb4785142ab8b4f250b0e6a4086f7d4397665c1e4840a9d809bec3b306aa8e5530d69da39289443876b79ffabdce5d4a774acab008efcedaaa671dba891ab013dff640c9799e572e1768e49d91e7abb6110e4e50109e2ba94aa42b3891cba732061a1c341c43f8dcc6242a77f18c7beeac74e4d021454cf1ade2dfdfa61384bb8c0b51913f29657cd0139f9fbdc359424a65a5737c9e2d36d7f44d8b47e4474fbfd5919718f7e95e4fa3b5eaca5cd1f6dcb68cf559e3d3d964db726ce5ae56bc624b301c94d4083411458e8a3acfb37606397dcc70e6a4a1a42b19caaedb0fc77fd41b6bd91541a28e9b2d44130ae38749bb0c6d37a2f00186726af1259b993f7c8965a87b6fe16bb8049fbbb592fa48c90def4b61d95fc123d948ece30e489626b4e004e183dbac2f58554c9e3c3428596ff284cc8ab5f7b3dddb3736dc4212744624a0c7589c0526b3b03ce88a57bb02bcb70f8daf230a56938337f1143dac4367e61ad80e1847bb594b39ee829acd0c2e739a21cda51797a8b96e907fe29bd0dc97697538ebc4b6688e3f61e7172980afe0048d9db619b71f11e087f9a364ffa75183f19c3e6d4d9e2cb9f4379d4f5d1495a6daeefcd2c84931bf9bbce7f0f207496cbfbe28770c821f3c35ad6400808fd7c66fc4eba0a3cc03c7a25e47b8de9579553b45eabc1e09db7ef86362689f7da2352a6ec14cd9bb37387c253705a29444260a1ababa3c6f84885899a1b4189fee300136b44ffd3157ea465d5616883ae3c35ce5154c03b61a9d4a38a88a49415940e37ae0ceaaedcdcccfca399b9476d8258365a7bb08310be74b5a39bc7a7cb12429454720f74f28fc17d623b266742d8ddaafa72047c7750cc98b2c6c4c961a4", + "container_hex": "505443455850303100000000000040008000000000000099fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7000b101112131415161718191a1b766563746f722d68706b655869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cfef94177bb023d9d7c5100833a3118c84c3622cffdfcf9ef269ab3301b3f743188f72cdcdec411e43f9e3b6ff4726081181a1862d419cec048c04a6740f6bc3dc40971f31f3d75eb2876381215eae53ba1bc3df6ff2732974b93e28dd4b3aebfefc9ef64b302a500b3ba6d9d2b1e80bd41e9e01083165b0ce61ae0568bfdd0636e8bb172c54541023f120fbd80918f5db8b1c22ba347f037bffc938708f0a186b1ff66d42027050f609b06bc367c43ab77b92b476c06dbe812f32f902dc3aa0adbda55006565875cc089e30dba2a649e73a6c91fb015d1127ca004c6678f0760ab4b5a6a37666f1b5dd32a91892153769d4be8a8ee8125ea1f840ebb2dd4ca01a9be433b1c5cf441f704f02e98e2ef48daa2353cb7c7c990b451d06a99eefe79231d13744f9ca044c7212de9e4d78af50805b48c2b6afac6ec497bc9d2f3204f4f2e9852d6ec0c4ecc82728eb02858ea3ec55e60b18c723b9407de281724bfa1760b7a93bf4cb067a4f2eaf16d7fe5b39be6d560b98bf8493bfcdc226eb3ce698af1d8fa7d32166b0627652086c7e01ae8f599341888cd4825ea9fde682d4f74d8fe9aa6b9d2a845eeacb30cde228bf8f018cd6d427c3b28f5748b046efd689542d9a4f6c84130458dbabc9df9fcf2b359ce5426062b1e7efbc37335e2b498163e0ba20fcacbcbd33bfbb25439bbc715f5df6b4643d47fdf479817f5d392cc3f39717ef3ef1fe2110ed5d7988782770ef08bad7625ae4208cfd3f5cd6cc04ba19ef48a7a11795b983ce0264a20ec850e11ff63a6cc589e09f7edf46c18fd72c545661ef9eac54028d9849bec0d88942636b37632ec22e70ea5aaa9566fc489a88a4b4c15138550018178e8900db4b7cff378db2fa43db0b8e9c405586736c26ad9566d36b12431474c383d191342a226182735a225ef7aea8e6f2b0d22d2ba92c6fe645341cdfb29b1d0961d073c1ac6233bdfb53ef8352c5d7285456046f2bac77ebac039c4f5dc6225815401291ea80e5250c361b156e78c570339f5048f62c5b69ec4d797d2114c02cf0123cb62ec92f480271587f0c64d98ef0e0ab0aa2d9c713bf7b74ab2e04d8a662921ff809c9b15b4f2a190462d7b4dda99fda98511ec260d1f22a2a69ef2406d06a0fc207c793ce9a479cc19335c746d48aa29022face0af22f158e2c4369d5a9747b43d12954bb2b82e8e042178b3c24c0b494a634c4b71d106693870a84fbaac03e40591582295bb94e256d7e075da00e78a4d2ff2b3dd9c6eb125c4493612f2c74070dd0487329cfb81244ab9bcb25646e40c3229f8e73a55429c8678d1b872ed674aac356c124d734f66e1cace3e5ecf8235a7734ba37c82c173ea43a2f92bc004e471a343be6fe7f404fb5a821365f32781bfb85347d25fd441d15e577d20901f53704195e8affc03368c0b3bd60aabe9ef14e854db1c9e6cd738d19ef1d20d506f7395fd096de963fee0ab7153be49322416426318aade21f20c02d7656d7bdc9aa54c7bc65e01ae7e0a92046c37385cf24bef7b860f2bfc18f28af6e0fb55c85f1c145a2aa1367daf479c326ff49b4e5f3e658057fe42af469aa29485c8a00cfc12da2d901058ffd2ed50915e76caa917b0e67e17fc5f29157694c30d798898af7f95a0ba07d91947c27199818283e8ce3a0adde1a4432c871e5322d863afd96b2159eb014ef98c6db40d6504355584d8fc47e249039cfffe1d1265ec037820a7a50613d4276caee2974e2e7b3d1651aea7855b0dbdd5555c29ca95f0bf919e6a5b0505148c5695193153aec27bdb02ce8da800a94ab0050fdebbc4c4ff50a169b9f262fca3cef3158bc844a9850ceed6ccee5f671bce70855a8efcee9a9543636c96cbbe6bd62a8b3a50259e8bf76118439f1eedc7d4e6c4aec08fcdd00184282b3d14624c9e0bf8f9c8b9b419bbd4bf3e02025f65b0828c94c4e2d77ca2e4f2781adac547b8fb4d9c75803ab55d72241945473b17dc819b4065f76469008d39825ebe36b9723014f72e528b9c1a083ddaba882a82decc88ff7c98c56e11ab722c79498ea1ce8ee53f367c23645eb78d0df19e6eb1803c8789455c72b2f2b087dc6b96c163f3cc17aec950b38ad776897c4af84afeadae1f0f07c5fc57d6fe7f0498e97c5843aab30da8d9bd8591d52252efd3ad13447cd210fed5985165714d871771a338e4cdf3afd9f8aab2abb7abb54b3096fed9ba818898fefd88db1973808a2c9aacda2515382375738cd3198624e45695fc3f9eb7b773c6373b8cd6cb749ea40bfa42911bdc366cf807a226a6d6020c3a4e838c6b0e1a30cff1c4215533430533d900c7aec49a172647246b02ca14fffdd9758a588423ea8a0c543b77edffb2147aa1801c4303abd780b21a3767341c78cd6ae526dd33a2bcce1c0cce433f08940df280da20a226da5e1f9dfc785b921e6e4c122f87d173999bf72c7a2b1afb925147dc823e9f0db2f3f234ee67d753d8ff2fbc54844fa1db423a45bda9b800680969902026de586b9684ecf6f117a38de98f2ccb4fc30028ab0464d9b781c95b10733f7c93ad61b0f848e8041ee4af5dc0724080836c65d015ba81777aa0ba484cb05e54ccb78bc32a8663a5379672e8ec9bd85d766b482ac661942a2f129f756f806577a09982fb15a39c75230ea1813f2b0f1e7c886100575521a230adbb3d466c020c8c943d7af51df009833d4cae36b2dee8fee49ce5bb892063a98aa9db5aadfc2014abcd69860c851a51afce672fb80061b7f8ead67b038c4c416f951dc1428b6c0af3d8ab2badfb516f5dcc95cc0a761c76e51474e6da2af93764cab60971f8e77121d23ce64044ab15cad3002694eb6f59f0fd8e79c2b22e80f16ecf6c3918f355343bbba47139ae4719199ce80c83e62c785c5dfa99dff8b275e21778dd541aa548dd89dba7dc34977def3efb8c1a971c141644f8bf41fff8900bac68aabbafd854c33fe856f2d4c218b785940243bf1c6a8b0562387f8745378df8ed45486a2d764555fc8491acca624db15235da92bc88978f521bc636674c26002780d8e1e9674a0ca3c8a9d2774e0d47803392ab6b1135d3a9794473143c41c397fadbe99933c03d9cd76fbd06f86846cf8ff8ac1decbdcfe0ba9c382f0dc5332ca713d05aeab76c5711ed7d2d44c2ad4a247d033d87ae1b285854ecd90d677f0e81ea1324ea940e25bc6a30ecbd4468bda32df9f069431d6cf9b564c8603a1de0f916acba3c720c9145351de49897572affb7b33800a4e52504a614defc8fd5f63f232d4d5e5b03af29b03ad5f44e3897118a90424d329f987ccad0b566542fdd99eaeb2534f160c98e7f959b107dd71a1969995adc3f4bba526d05986d5663ca9f22390bf6c74b39b94f463dff0bd07f360101beb864f3e20227c5686edd2384753cca29c12dd8d9bf7626b79aef2481039d437679642867062feacd6d9ffba70a4913181f3ecb6f769c3b992ab1333f1311a9a99d659fc728673f31f6d875314e1e50413b3a28cbb41b39b43eb7d767aecdb59a73d8dca7442ab730ed6bfefb5111c554d10db07dc62e5e2023a4bbed6b5151fe48756c87337f7abcb318638a7164f2faae0d36b6944d4bb7c5377f1f857166d847184f6fa2d5fcaabc34970cf60884ddf647d9b4dac0de24cf36bdc22b230ff40069b595455e8b8f5a02466748cc1970064051843b087b1ecb7f64bd65cb2b5ab651527e8ae855928b83c7489bec8866c22002b4c3bab1a639392e0f4a45e82b6fb0cea9851edbf593dfbdd9e88effb058d8c05399193b56fb5de3b2f5a1d4c5725a2b2e28151d498cd4cd581fb3a50c9e747634fcca07570ce2b3e556d5007798ed5416d2a02a886ab2dad2fc337e09bab70a8692318530755dade307f509b8da79c63f599b385b172bfd6d5fc24f212d41e7b7b8a8df206f9238126d4729f69bfb3446794b2ee0666779818f00e1dcfd661a85aec1a0045b03758c6471c3a19c6fc85c1fadc9a436b8ba4df1f4120b6f7bd2b9f931cfeb2ab205e067ad2cce4e3ca2b8e16c2d260f77c205fb6364bea4ab810378c653d559b08e88278fc358e6798edd7ba81d4941e0185cbe0072fe7a70cc89fa573879b40220576003f3fe6beb7b8c4f763ced79a510299e5e5175b6d52b1d51f7fe328856f6fc0a33d34c45452b23939e69dde949ba59f13d55167bf06a1bc9d9c87eeac818a6e7c99463a6a60e6da6de43ff5ed82d8e485561fd60960172bbfc3d1602f6860157ef04614052557177f6430aa2cfc7bb8ea143a1244cd21db9e906522147cbfd2a141e566c05d3cc6fdb20357efc8618c1e53a2896d2cd7335165380f54419f714278301fb16b0e50288e77ed887aa04aad0a2459618f8e2605590d012051a79c020b95a1ef025587299b34eee3228705cd03a1fef63303e3939ecb174dcb0ccfc938123dcc4d4b516f70cca86b61a0e6a3052eae4023c34e46a166b6e64a18ba816280883bf47160ce9734cb6983e428d66075350ce276045d41", "content_key_hex": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", "content_nonce_hex": "101112131415161718191a1b", "context_jcs_utf8_hex": "7b2262756e646c655f666f726d6174223a227061727469636570732d72657365617263682d62756e646c652d7631222c2262756e646c655f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303939222c22636f6e66696775726174696f6e5f736861323536223a2266623264666561363338636136323130653764313562663132653962663363393130303964353463386135383164633361343737616363643732326262396337222c22726573656172636865725f6b65795f6964223a22766563746f722d68706b65227d", - "document_jcs_utf8_hex": "7b2262756e646c655f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303939222c2262756e646c655f6b696e64223a226175746f6d617469635f75706c6f6164222c22636f6e66696775726174696f6e223a7b2261737369676e65645f7061727469636970616e745f6964223a6e756c6c2c22636f6c6c6563746f7273223a5b7b22636f6e666967223a7b226d6178696d756d5f7265706f72745f6c6174656e63795f7573223a313030303030302c2273616d706c696e675f706572696f645f7573223a3130303030307d2c226964223a22616363656c65726f6d657465722e7631222c227265717569726564223a66616c73657d2c7b22636f6e666967223a7b7d2c226964223a226170705f6c6966656379636c652e7631222c227265717569726564223a747275657d5d2c22636f6e66696775726174696f6e5f6964223a22766563746f722d636f6e666967222c22636f6e73656e74223a7b22646f63756d656e745f76657273696f6e223a227631222c2273756d6d617279223a2250726f746f636f6c20766563746f7220636f6e73656e742e227d2c226475726174696f6e5f686f757273223a32342c226578706572696d656e745f6964223a22766563746f722d7374756479222c22657870697265735f6174223a22323033302d30312d30315430303a30303a30305a222c226578706f7274223a7b2268706b655f7075626c69635f6b6579223a225a4c45427364432d576f6345765165506d4a55414838412d6a702d564976474933524b4e6d456255684759222c22726573656172636865725f6b65795f6964223a22766563746f722d68706b65227d2c22696e74657276656e74696f6e73223a5b5d2c226973737565645f6174223a22323032362d30312d30315430303a30303a30305a222c226d696e696d756d5f636c69656e745f76657273696f6e223a2237222c22706c6174666f726d223a22616e64726f6964222c22707572706f7365223a224578657263697365207468652064657374727563746976652050726f746f636f6c20763120636f6e74726163742e222c2272657365617263686572223a7b22636f6e74616374223a22766563746f72406578616d706c652e696e76616c6964222c226e616d65223a2250726f746f636f6c20566563746f72227d2c22736368656d615f76657273696f6e223a312c227369676e6572223a7b226b65795f6964223a22766563746f722d7369676e6572222c227075626c69635f6b6579223a22656256574c6f5f6d56506c41654c4553364b6d4c703541666854726d6c623758344f4f52433630456c6d51227d2c2273746f72616765223a7b226d6178696d756d5f6c6f63616c5f6279746573223a31363737373231367d2c2273757276657973223a5b5d2c227469746c65223a2250726f746f636f6c20766563746f72222c2275706c6f6164223a7b7d7d2c22636f6e66696775726174696f6e5f736861323536223a2266623264666561363338636136323130653764313562663132653962663363393130303964353463386135383164633361343737616363643732326262396337222c22636f6e66696775726174696f6e5f7369676e6174757265223a7b227369676e6174757265223a22495a5a5142345a4b74766e34724d7a627479344c6930354b424756516871645476723659526f5a45396466346e563331763754726d33362d687376656b6d5233696e564a536379467772306e713567464c4e6a784251222c227369676e65725f6b65795f6964223a22766563746f722d7369676e6572227d2c226578706572696d656e74223a7b2261737369676e65645f7061727469636970616e745f6964223a6e756c6c2c22636f6e66696775726174696f6e5f6964223a22766563746f722d636f6e666967222c2264757261626c655f7468726f7567685f73657175656e6365223a2231222c226576656e745f636f756e74223a2231222c226576656e7473223a5b7b22636f6c6c6563746f725f6964223a226170705f6c6966656379636c652e7631222c226669656c6473223a7b2261637469766974795f636c617373223a22766563746f722e4163746976697479227d2c226f627365727665645f74696d65223a7b22626f6f745f73657373696f6e5f6964223a22626f6f742d766563746f72222c226d6f6e6f746f6e69635f74696d655f6e616e6f73223a2232303030222c2277616c6c5f74696d655f7574635f6d696c6c6973223a2231303030227d2c227061796c6f61645f736368656d615f76657273696f6e223a312c227061796c6f61645f74797065223a2241435449564954595f43524541544544222c2273657175656e63655f6e756d626572223a2231227d5d2c226578706572696d656e745f6964223a22766563746f722d7374756479222c2266697273745f73657175656e63655f6e756d626572223a2231222c226c6173745f73657175656e63655f6e756d626572223a2231222c226e6578745f73657175656e63655f6e756d626572223a2232222c227061727469636970616e745f696e7374616e63655f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303137222c2272657461696e65645f66726f6d5f73657175656e6365223a2231222c227374617465223a2252554e4e494e47222c227472616e736974696f6e73223a5b7b2266726f6d223a22494d504f52544544222c22726561736f6e223a22434f4e46494755524154494f4e5f5349474e41545552455f5645524946494544222c2274696d65223a7b22626f6f745f73657373696f6e5f6964223a22626f6f742d766563746f72222c226d6f6e6f746f6e69635f74696d655f6e616e6f73223a22313030222c2277616c6c5f74696d655f7574635f6d696c6c6973223a22313030227d2c22746f223a22434f4e4649475f5645524946494544227d2c7b2266726f6d223a22434f4e4649475f5645524946494544222c22726561736f6e223a22434f4e53454e545f5245564945575f4f50454e4544222c2274696d65223a7b22626f6f745f73657373696f6e5f6964223a22626f6f742d766563746f72222c226d6f6e6f746f6e69635f74696d655f6e616e6f73223a22323030222c2277616c6c5f74696d655f7574635f6d696c6c6973223a22323030227d2c22746f223a22434f4e53454e545f50454e44494e47227d2c7b2266726f6d223a22434f4e53454e545f50454e44494e47222c22726561736f6e223a22434f4e53454e545f4143434550544544222c2274696d65223a7b22626f6f745f73657373696f6e5f6964223a22626f6f742d766563746f72222c226d6f6e6f746f6e69635f74696d655f6e616e6f73223a22333030222c2277616c6c5f74696d655f7574635f6d696c6c6973223a22333030227d2c22746f223a224143434553535f5345545550227d2c7b2266726f6d223a224143434553535f5345545550222c22726561736f6e223a224143434553535f505245464c494748545f504153534544222c2274696d65223a7b22626f6f745f73657373696f6e5f6964223a22626f6f742d766563746f72222c226d6f6e6f746f6e69635f74696d655f6e616e6f73223a22343030222c2277616c6c5f74696d655f7574635f6d696c6c6973223a22343030227d2c22746f223a225245414459227d2c7b2266726f6d223a225245414459222c22726561736f6e223a225041525449434950414e545f53544152544544222c2274696d65223a7b22626f6f745f73657373696f6e5f6964223a22626f6f742d766563746f72222c226d6f6e6f746f6e69635f74696d655f6e616e6f73223a22353030222c2277616c6c5f74696d655f7574635f6d696c6c6973223a22353030227d2c22746f223a2252554e4e494e47227d5d2c2275706c6f616465645f7468726f7567685f73657175656e6365223a2230227d2c226578706f727465645f61745f7574635f6d696c6c6973223a223130303030222c22666f726d6174223a227061727469636570732d72657365617263682d62756e646c652d7631222c2270726f6475636572223a7b22636c69656e745f76657273696f6e223a2237222c22706c6174666f726d223a22616e64726f6964227d7d", + "document_jcs_utf8_hex": "7b2262756e646c655f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303939222c2262756e646c655f6b696e64223a226175746f6d617469635f75706c6f6164222c22636f6e66696775726174696f6e223a7b2261737369676e65645f7061727469636970616e745f6964223a6e756c6c2c22636f6c6c6563746f7273223a5b7b22636f6e666967223a7b226d6178696d756d5f7265706f72745f6c6174656e63795f7573223a313030303030302c2273616d706c696e675f706572696f645f7573223a3130303030307d2c226964223a22616363656c65726f6d657465722e7631222c227265717569726564223a66616c73657d2c7b22636f6e666967223a7b7d2c226964223a226170705f6c6966656379636c652e7631222c227265717569726564223a747275657d5d2c22636f6e66696775726174696f6e5f6964223a22766563746f722d636f6e666967222c22636f6e73656e74223a7b22646f63756d656e745f76657273696f6e223a227631222c2273756d6d617279223a2250726f746f636f6c20766563746f7220636f6e73656e742e227d2c226475726174696f6e5f686f757273223a32342c226578706572696d656e745f6964223a22766563746f722d7374756479222c22657870697265735f6174223a22323033302d30312d30315430303a30303a30305a222c226578706f7274223a7b2268706b655f7075626c69635f6b6579223a225a4c45427364432d576f6345765165506d4a55414838412d6a702d564976474933524b4e6d456255684759222c22726573656172636865725f6b65795f6964223a22766563746f722d68706b65227d2c22696e74657276656e74696f6e73223a5b5d2c226973737565645f6174223a22323032362d30312d30315430303a30303a30305a222c226d696e696d756d5f636c69656e745f76657273696f6e223a2237222c22706c6174666f726d223a22616e64726f6964222c22707572706f7365223a224578657263697365207468652064657374727563746976652050726f746f636f6c20763120636f6e74726163742e222c2272657365617263686572223a7b22636f6e74616374223a22766563746f72406578616d706c652e696e76616c6964222c226e616d65223a2250726f746f636f6c20566563746f72227d2c22736368656d615f76657273696f6e223a312c227369676e6572223a7b226b65795f6964223a22766563746f722d7369676e6572222c227075626c69635f6b6579223a22656256574c6f5f6d56506c41654c4553364b6d4c703541666854726d6c623758344f4f52433630456c6d51227d2c2273746f72616765223a7b226d6178696d756d5f6c6f63616c5f6279746573223a31363737373231367d2c2273757276657973223a5b5d2c227469746c65223a2250726f746f636f6c20766563746f72222c2275706c6f6164223a7b7d7d2c22636f6e66696775726174696f6e5f736861323536223a2266623264666561363338636136323130653764313562663132653962663363393130303964353463386135383164633361343737616363643732326262396337222c22636f6e66696775726174696f6e5f7369676e6174757265223a7b227369676e6174757265223a22495a5a5142345a4b74766e34724d7a627479344c6930354b424756516871645476723659526f5a45396466346e563331763754726d33362d687376656b6d5233696e564a536379467772306e713567464c4e6a784251222c227369676e65725f6b65795f6964223a22766563746f722d7369676e6572227d2c226578706572696d656e74223a7b2261737369676e65645f7061727469636970616e745f6964223a6e756c6c2c22636f6e66696775726174696f6e5f6964223a22766563746f722d636f6e666967222c2264757261626c655f7468726f7567685f73657175656e6365223a2231222c226576656e745f636f756e74223a2231222c226576656e7473223a5b7b22636f6c6c6563746f725f6964223a226170705f6c6966656379636c652e7631222c226669656c6473223a7b2261637469766974795f636c617373223a22766563746f722e4163746976697479227d2c226f627365727665645f74696d65223a7b22626f6f745f73657373696f6e5f6964223a22626f6f742d766563746f72222c226d6f6e6f746f6e69635f74696d655f6e616e6f73223a2232303030222c2277616c6c5f74696d655f7574635f6d696c6c6973223a2231303030227d2c227061796c6f61645f736368656d615f76657273696f6e223a312c227061796c6f61645f74797065223a2241435449564954595f43524541544544222c2273657175656e63655f6e756d626572223a2231227d5d2c226578706572696d656e745f6964223a22766563746f722d7374756479222c2266697273745f73657175656e63655f6e756d626572223a2231222c226c6173745f73657175656e63655f6e756d626572223a2231222c226e6578745f73657175656e63655f6e756d626572223a2232222c227061727469636970616e745f696e7374616e63655f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303137222c2272657461696e65645f66726f6d5f73657175656e6365223a2231222c227374617465223a22504155534544222c227472616e736974696f6e73223a5b7b2266726f6d223a22494d504f52544544222c22726561736f6e223a22434f4e46494755524154494f4e5f5349474e41545552455f5645524946494544222c2274696d65223a7b22626f6f745f73657373696f6e5f6964223a22626f6f742d766563746f72222c226d6f6e6f746f6e69635f74696d655f6e616e6f73223a22313030222c2277616c6c5f74696d655f7574635f6d696c6c6973223a22313030227d2c22746f223a22434f4e4649475f5645524946494544227d2c7b2266726f6d223a22434f4e4649475f5645524946494544222c22726561736f6e223a22434f4e53454e545f5245564945575f4f50454e4544222c2274696d65223a7b22626f6f745f73657373696f6e5f6964223a22626f6f742d766563746f72222c226d6f6e6f746f6e69635f74696d655f6e616e6f73223a22323030222c2277616c6c5f74696d655f7574635f6d696c6c6973223a22323030227d2c22746f223a22434f4e53454e545f50454e44494e47227d2c7b2266726f6d223a22434f4e53454e545f50454e44494e47222c22726561736f6e223a22434f4e53454e545f4143434550544544222c2274696d65223a7b22626f6f745f73657373696f6e5f6964223a22626f6f742d766563746f72222c226d6f6e6f746f6e69635f74696d655f6e616e6f73223a22333030222c2277616c6c5f74696d655f7574635f6d696c6c6973223a22333030227d2c22746f223a224143434553535f5345545550227d2c7b2266726f6d223a224143434553535f5345545550222c22726561736f6e223a224143434553535f505245464c494748545f504153534544222c2274696d65223a7b22626f6f745f73657373696f6e5f6964223a22626f6f742d766563746f72222c226d6f6e6f746f6e69635f74696d655f6e616e6f73223a22343030222c2277616c6c5f74696d655f7574635f6d696c6c6973223a22343030227d2c22746f223a225245414459227d2c7b2266726f6d223a225245414459222c22726561736f6e223a225041525449434950414e545f53544152544544222c2274696d65223a7b22626f6f745f73657373696f6e5f6964223a22626f6f742d766563746f72222c226d6f6e6f746f6e69635f74696d655f6e616e6f73223a22353030222c2277616c6c5f74696d655f7574635f6d696c6c6973223a22353030227d2c22746f223a2252554e4e494e47227d2c7b2266726f6d223a2252554e4e494e47222c22726561736f6e223a2252455155495245445f4143434553535f4d495353494e47222c2274696d65223a7b22626f6f745f73657373696f6e5f6964223a22626f6f742d766563746f72222c226d6f6e6f746f6e69635f74696d655f6e616e6f73223a2233303030222c2277616c6c5f74696d655f7574635f6d696c6c6973223a2232303030227d2c22746f223a22504155534544227d5d2c2275706c6f616465645f7468726f7567685f73657175656e6365223a2230227d2c226578706f727465645f61745f7574635f6d696c6c6973223a223130303030222c22666f726d6174223a227061727469636570732d72657365617263682d62756e646c652d7631222c2270726f6475636572223a7b22636c69656e745f76657273696f6e223a2237222c22706c6174666f726d223a22616e64726f6964227d7d", "hpke_ephemeral_private_key_base64url": "ISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0-P0A", "hpke_wrapped_content_key_hex": "5869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67bdb5acd115312c3ad696ff28f393f19c14b14bcb57bd2d5e4bd23c1f0a62e1d3c8be8386ad013c5e7df9bc4fb9175f2cf", "researcher_private_key_base64url": "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVpbXF1eX2A", "researcher_public_key_base64url": "ZLEBsdC-WocEvQePmJUAH8A-jp-VIvGI3RKNmEbUhGY", - "sha256": "bf6d798032555a39807ee24d07867f935503cf86d0fbe72680ab0f86b1b60897" + "sha256": "888b289fc2db9f90b81f28e58db1a80663b46dbf7e4167733248751c60951bb6" }, "signed_configuration": { "canonical_jcs_sha256": "fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7", @@ -449,15 +449,15 @@ ] }, "upload_receipt": { - "canonical_jcs_utf8_hex": "7b2262756e646c655f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303939222c22627974655f636f756e74223a2233313931222c22636f6e66696775726174696f6e5f736861323536223a2266623264666561363338636136323130653764313562663132653962663363393130303964353463386135383164633361343737616363643732326262396337222c226576656e745f636f756e74223a2231222c2266697273745f73657175656e63655f6e756d626572223a2231222c226c6173745f73657175656e63655f6e756d626572223a2231222c22736861323536223a2262663664373938303332353535613339383037656532346430373836376639333535303363663836643066626537323638306162306638366231623630383937227d", + "canonical_jcs_utf8_hex": "7b2262756e646c655f6964223a2230303030303030302d303030302d343030302d383030302d303030303030303030303939222c22627974655f636f756e74223a2233333539222c22636f6e66696775726174696f6e5f736861323536223a2266623264666561363338636136323130653764313562663132653962663363393130303964353463386135383164633361343737616363643732326262396337222c226576656e745f636f756e74223a2231222c2266697273745f73657175656e63655f6e756d626572223a2231222c226c6173745f73657175656e63655f6e756d626572223a2231222c22736861323536223a2238383862323839666332646239663930623831663238653538646231613830363633623436646266376534313637373333323438373531633630393531626236227d", "value": { "bundle_id": "00000000-0000-4000-8000-000000000099", - "byte_count": "3191", + "byte_count": "3359", "configuration_sha256": "fb2dfea638ca6210e7d15bf12e9bf3c91009d54c8a581dc3a477accd722bb9c7", "event_count": "1", "first_sequence_number": "1", "last_sequence_number": "1", - "sha256": "bf6d798032555a39807ee24d07867f935503cf86d0fbe72680ab0f86b1b60897" + "sha256": "888b289fc2db9f90b81f28e58db1a80663b46dbf7e4167733248751c60951bb6" } } } diff --git a/tools/changelog_release_notes.py b/tools/changelog_release_notes.py new file mode 100644 index 0000000..31ae275 --- /dev/null +++ b/tools/changelog_release_notes.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import argparse +import datetime as dt +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_CHANGELOG = ROOT / "CHANGELOG.md" +TAG_PATTERN = re.compile( + r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" + r"(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?" +) + + +class ChangelogError(ValueError): + pass + + +def extract_release_notes(changelog: str, tag: str) -> str: + if TAG_PATTERN.fullmatch(tag) is None: + raise ChangelogError(f"Invalid release tag: {tag}") + + lines = changelog.splitlines() + heading_prefix = f"## {tag} " + matches = [index for index, line in enumerate(lines) if line.startswith(heading_prefix)] + if len(matches) != 1: + raise ChangelogError(f"Expected exactly one changelog section for {tag}; found {len(matches)}") + + start = matches[0] + heading = lines[start] + heading_match = re.fullmatch(rf"## {re.escape(tag)} — ([0-9]{{4}}-[0-9]{{2}}-[0-9]{{2}})", heading) + if heading_match is None: + raise ChangelogError(f"Malformed changelog heading for {tag}: {heading}") + try: + dt.date.fromisoformat(heading_match.group(1)) + except ValueError as failure: + raise ChangelogError(f"Invalid changelog date for {tag}") from failure + + end = next( + (index for index in range(start + 1, len(lines)) if lines[index].startswith("## ")), + len(lines), + ) + notes = "\n".join(lines[start + 1 : end]).strip() + if not notes: + raise ChangelogError(f"Changelog section for {tag} is empty") + return f"{notes}\n" + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Extract the exact CHANGELOG section used as GitHub Release notes.", + ) + parser.add_argument("tag") + parser.add_argument("destination", type=Path) + parser.add_argument("--changelog", type=Path, default=DEFAULT_CHANGELOG) + arguments = parser.parse_args() + + notes = extract_release_notes(arguments.changelog.read_text(encoding="utf-8"), arguments.tag) + arguments.destination.write_text(notes, encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tools/conformance/kotlin/ProtocolConformanceTest.kt b/tools/conformance/kotlin/ProtocolConformanceTest.kt index b0e731a..6e9bd44 100644 --- a/tools/conformance/kotlin/ProtocolConformanceTest.kt +++ b/tools/conformance/kotlin/ProtocolConformanceTest.kt @@ -67,6 +67,34 @@ class ProtocolConformanceTest { assertEquals(expected.stringAt("sha256"), receipt.sha256) } + @Test + fun requiredAccessPauseSharedBundleIsAcceptedByKotlin() { + val valid = corpus.objectAt("valid") + val signed = valid.objectAt("signed_configuration") + val configuration = StudyConfigurationCodec.decode( + signed.hexAt("canonical_jcs_utf8_hex"), + ) + val bundle = valid.objectAt("bundle") + val plaintext = ByteArrayOutputStream() + val header = ResearchExport.decrypt( + bundle.hexAt("container_hex").inputStream(), + plaintext, + bundle.base64UrlAt("researcher_private_key_base64url"), + configuration, + ) + + ResearchBundleVerifier.verify(plaintext.toByteArray(), header, configuration) + assertArrayEquals(bundle.hexAt("document_jcs_utf8_hex"), plaintext.toByteArray()) + val experiment = JsonParser.parseString(plaintext.toString(Charsets.UTF_8)) + .asJsonObject + .objectAt("experiment") + assertEquals("PAUSED", experiment.stringAt("state")) + val transition = experiment.getAsJsonArray("transitions").last().asJsonObject + assertEquals("RUNNING", transition.stringAt("from")) + assertEquals("PAUSED", transition.stringAt("to")) + assertEquals("REQUIRED_ACCESS_MISSING", transition.stringAt("reason")) + } + @Test fun everyHostileVectorFailsItsKotlinEntrypoint() { val valid = corpus.objectAt("valid") diff --git a/tools/conformance/typescript/protocol-vectors.spec.ts b/tools/conformance/typescript/protocol-vectors.spec.ts index db33a8b..b0c64a5 100644 --- a/tools/conformance/typescript/protocol-vectors.spec.ts +++ b/tools/conformance/typescript/protocol-vectors.spec.ts @@ -58,6 +58,12 @@ describe('shared Protocol v1 conformance corpus', () => { expect(opened.ok).toBe(true); if (!opened.ok) return; expect(new TextEncoder().encode(opened.bundle.text)).toEqual(bytes(bundle.document_jcs_utf8_hex)); + expect(opened.bundle.document.experiment.state).toBe('PAUSED'); + expect(opened.bundle.document.experiment.transitions.at(-1)).toMatchObject({ + from: 'RUNNING', + reason: 'REQUIRED_ACCESS_MISSING', + to: 'PAUSED' + }); }); for (const vector of corpus.hostile) { diff --git a/tools/generate_protocol_vectors.mjs b/tools/generate_protocol_vectors.mjs index 0685f90..9eb0c68 100644 --- a/tools/generate_protocol_vectors.mjs +++ b/tools/generate_protocol_vectors.mjs @@ -155,7 +155,7 @@ const documentValue = { next_sequence_number: '2', participant_instance_id: '00000000-0000-4000-8000-000000000017', retained_from_sequence: '1', - state: 'RUNNING', + state: 'PAUSED', transitions: [ { from: 'IMPORTED', @@ -186,6 +186,12 @@ const documentValue = { reason: 'PARTICIPANT_STARTED', time: { boot_session_id: 'boot-vector', monotonic_time_nanos: '500', wall_time_utc_millis: '500' }, to: 'RUNNING' + }, + { + from: 'RUNNING', + reason: 'REQUIRED_ACCESS_MISSING', + time: { boot_session_id: 'boot-vector', monotonic_time_nanos: '3000', wall_time_utc_millis: '2000' }, + to: 'PAUSED' } ], uploaded_through_sequence: '0' diff --git a/tools/retired_identity_audit.py b/tools/retired_identity_audit.py index 228d959..cef1d97 100644 --- a/tools/retired_identity_audit.py +++ b/tools/retired_identity_audit.py @@ -50,7 +50,6 @@ "CHANGELOG.md": "the release history; naming the identity each release carried is its job", "app/src/androidTest/kotlin/cool/jacoblin/particeps/AndroidConfigurationImportTest.kt": "retired-identity rejection fixture: import must fail closed on the old magic", - "docs/participant-guide.md": "tells a participant what the app they already installed was called", "docs/researcher-guide.md": "states that a pre-rename configuration or export is refused", "protocol/v1/README.md": "normative statement of the retired identity's rejection", "protocol/v1/join-link-vectors.json": "retired-scheme rejection fixture", diff --git a/tools/tests/test_changelog_release_notes.py b/tools/tests/test_changelog_release_notes.py new file mode 100644 index 0000000..0f4682e --- /dev/null +++ b/tools/tests/test_changelog_release_notes.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import unittest + +from tools import changelog_release_notes + + +class ChangelogReleaseNotesTest(unittest.TestCase): + def test_repository_release_notes_include_update_instructions(self) -> None: + changelog = changelog_release_notes.DEFAULT_CHANGELOG.read_text(encoding="utf-8") + + notes = changelog_release_notes.extract_release_notes(changelog, "v1.0.0-rc.6") + + self.assertIn("Coming from `v1.0.0-rc.5`", notes) + self.assertIn("Do not uninstall rc.5 first", notes) + self.assertNotIn("## v1.0.0-rc.5", notes) + + def test_exact_section_is_selected(self) -> None: + changelog = """# Changelog + +## v1.2.0 — 2026-08-09 + +Current notes. + +## v1.1.0 — 2026-08-08 + +Older notes. +""" + + self.assertEqual( + changelog_release_notes.extract_release_notes(changelog, "v1.2.0"), + "Current notes.\n", + ) + + def test_missing_or_duplicate_section_is_rejected(self) -> None: + missing = "# Changelog\n\n## Unreleased\n" + duplicate = """## v1.2.0 — 2026-08-09 +First. +## v1.2.0 — 2026-08-09 +Second. +""" + + with self.assertRaisesRegex(changelog_release_notes.ChangelogError, "found 0"): + changelog_release_notes.extract_release_notes(missing, "v1.2.0") + with self.assertRaisesRegex(changelog_release_notes.ChangelogError, "found 2"): + changelog_release_notes.extract_release_notes(duplicate, "v1.2.0") + + def test_invalid_tag_heading_date_and_empty_section_are_rejected(self) -> None: + with self.assertRaisesRegex(changelog_release_notes.ChangelogError, "Invalid release tag"): + changelog_release_notes.extract_release_notes("", "release-latest") + with self.assertRaisesRegex(changelog_release_notes.ChangelogError, "Malformed"): + changelog_release_notes.extract_release_notes( + "## v1.2.0 - 2026-08-09\nNotes.\n", + "v1.2.0", + ) + with self.assertRaisesRegex(changelog_release_notes.ChangelogError, "Invalid changelog date"): + changelog_release_notes.extract_release_notes( + "## v1.2.0 — 2026-02-30\nNotes.\n", + "v1.2.0", + ) + with self.assertRaisesRegex(changelog_release_notes.ChangelogError, "empty"): + changelog_release_notes.extract_release_notes( + "## v1.2.0 — 2026-08-09\n\n## v1.1.0 — 2026-08-08\nNotes.\n", + "v1.2.0", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_verify_release_apk.py b/tools/tests/test_verify_release_apk.py new file mode 100644 index 0000000..cffedb0 --- /dev/null +++ b/tools/tests/test_verify_release_apk.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from tools import verify_release_apk + + +class VerifyReleaseApkTest(unittest.TestCase): + def test_repository_anchor_is_strict_and_workflow_does_not_duplicate_it(self) -> None: + expected = verify_release_apk.read_expected_certificate_sha256() + workflow = (verify_release_apk.ROOT / ".github/workflows/release.yml").read_text( + encoding="utf-8", + ) + + self.assertRegex(expected, r"\A[0-9a-f]{64}\Z") + self.assertNotIn(expected, workflow) + + def test_anchor_rejects_noncanonical_content(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "certificate.sha256" + for invalid in ("a" * 64, "A" * 64 + "\n", "a" * 64 + "\r\n", "a" * 64 + "\n\n"): + with self.subTest(invalid=repr(invalid)): + path.write_text(invalid, encoding="ascii") + with self.assertRaisesRegex( + verify_release_apk.ReleaseApkVerificationError, + "exactly 64 lowercase", + ): + verify_release_apk.read_expected_certificate_sha256(path) + + def test_exactly_one_expected_signer_is_accepted(self) -> None: + expected = "a" * 64 + output = ( + "Verifies\n" + "Number of signers: 1\n" + f"V2 Signer: certificate SHA-256 digest: {expected}\n" + ) + + self.assertEqual(verify_release_apk.require_expected_signer(output, expected), expected) + + def test_missing_multiple_and_malformed_signers_are_rejected(self) -> None: + valid_digest = "a" * 64 + cases = { + "missing count": f"V2 Signer: certificate SHA-256 digest: {valid_digest}\n", + "multiple signers": ( + "Number of signers: 2\n" + f"V2 Signer: certificate SHA-256 digest: {valid_digest}\n" + f"V3 Signer: certificate SHA-256 digest: {'b' * 64}\n" + ), + "duplicate certificate reports": ( + "Number of signers: 1\n" + f"V2 Signer: certificate SHA-256 digest: {valid_digest}\n" + f"V3 Signer: certificate SHA-256 digest: {valid_digest}\n" + ), + "missing digest": "Number of signers: 1\n", + "malformed digest": ( + "Number of signers: 1\n" + "V2 Signer: certificate SHA-256 digest: not-a-digest\n" + ), + } + for name, output in cases.items(): + with self.subTest(name=name): + with self.assertRaises(verify_release_apk.ReleaseApkVerificationError): + verify_release_apk.extract_single_signer_certificate_sha256(output) + + def test_unexpected_certificate_is_rejected(self) -> None: + output = ( + "Number of signers: 1\n" + f"V2 Signer: certificate SHA-256 digest: {'b' * 64}\n" + ) + + with self.assertRaisesRegex( + verify_release_apk.ReleaseApkVerificationError, + "mismatch", + ): + verify_release_apk.require_expected_signer(output, "a" * 64) + + def test_apksigner_failure_is_rejected_even_with_matching_output(self) -> None: + expected = verify_release_apk.read_expected_certificate_sha256() + result = mock.Mock( + returncode=1, + stdout=( + "Number of signers: 1\n" + f"V2 Signer: certificate SHA-256 digest: {expected}\n" + ), + stderr="verification failed\n", + ) + with ( + mock.patch.object(verify_release_apk.subprocess, "run", return_value=result), + mock.patch("builtins.print"), + self.assertRaisesRegex( + verify_release_apk.ReleaseApkVerificationError, + "exited with 1", + ), + ): + verify_release_apk.verify_release_apk( + Path("/sdk/apksigner"), + Path("/tmp/final.apk"), + ) + + def test_release_jobs_have_minimum_permissions_and_call_verifier_before_publish(self) -> None: + workflow = (verify_release_apk.ROOT / ".github/workflows/release.yml").read_text( + encoding="utf-8", + ) + header, jobs = workflow.split("\njobs:\n", maxsplit=1) + instrumented, release = jobs.split("\n release:\n", maxsplit=1) + + self.assertNotIn("contents: write", header) + self.assertIn(" permissions:\n contents: read\n", instrumented) + self.assertIn(" permissions:\n contents: write\n", release) + verifier = 'python3 tools/verify_release_apk.py "$build_tools/apksigner" "$release_apk"' + self.assertIn(verifier, release) + self.assertLess(release.index(verifier), release.index("sha256sum")) + self.assertLess(release.index(verifier), release.index("gh release")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/validate_protocol_vectors.py b/tools/validate_protocol_vectors.py index c22da43..495c07c 100644 --- a/tools/validate_protocol_vectors.py +++ b/tools/validate_protocol_vectors.py @@ -131,6 +131,35 @@ def validate(path: Path = VECTORS) -> None: fail("bundle context does not match framing") if len(raw(bundle["hpke_wrapped_content_key_hex"], "wrapped key")) != 80: fail("HPKE wrapped key must be 80 bytes") + document_bytes = raw(bundle["document_jcs_utf8_hex"], "bundle document") + document = json.loads(document_bytes) + if canonical_json(document) != document_bytes: + fail("bundle document is not canonical JSON") + pause_experiment = document.get("experiment") + pause_transitions = ( + pause_experiment.get("transitions") + if isinstance(pause_experiment, dict) + else None + ) + last_transition = ( + pause_transitions[-1] + if isinstance(pause_transitions, list) and pause_transitions + else None + ) + if ( + not isinstance(last_transition, dict) + or pause_experiment.get("state") != "PAUSED" + or { + key: last_transition.get(key) + for key in ("from", "reason", "to") + } + != { + "from": "RUNNING", + "reason": "REQUIRED_ACCESS_MISSING", + "to": "PAUSED", + } + ): + fail("required-access pause bundle does not carry its normative transition") request = valid["upload_request"] if set(request) != {"bundle_format", "media_type", "routing_headers"}: fail("valid upload request fixture is not closed-world") diff --git a/tools/verify_release_apk.py b/tools/verify_release_apk.py new file mode 100644 index 0000000..6969fdb --- /dev/null +++ b/tools/verify_release_apk.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_CERTIFICATE_SHA256_PATH = ROOT / ".github" / "android-release-signing-certificate.sha256" +EXPECTED_SHA256_PATTERN = re.compile(rb"[0-9a-f]{64}\n") +ACTUAL_SHA256_PATTERN = re.compile(r"[0-9a-fA-F]{64}") +SIGNER_COUNT_PATTERN = re.compile(r"^Number of signers: ([0-9]+)$", re.MULTILINE) +CERTIFICATE_SHA256_LABEL = "certificate SHA-256 digest: " + + +class ReleaseApkVerificationError(ValueError): + pass + + +def read_expected_certificate_sha256(path: Path = DEFAULT_CERTIFICATE_SHA256_PATH) -> str: + value = path.read_bytes() + if EXPECTED_SHA256_PATTERN.fullmatch(value) is None: + raise ReleaseApkVerificationError( + f"{path} must contain exactly 64 lowercase hexadecimal characters and one newline", + ) + return value[:-1].decode("ascii") + + +def extract_single_signer_certificate_sha256(apksigner_output: str) -> str: + signer_counts = SIGNER_COUNT_PATTERN.findall(apksigner_output) + if signer_counts != ["1"]: + raise ReleaseApkVerificationError( + f"expected exactly one APK signer, found reports {signer_counts or 'none'}", + ) + + certificate_lines = [ + line + for line in apksigner_output.splitlines() + if CERTIFICATE_SHA256_LABEL in line + ] + if len(certificate_lines) != 1: + raise ReleaseApkVerificationError( + f"expected exactly one signer certificate SHA-256, found {len(certificate_lines)}", + ) + actual = certificate_lines[0].split(CERTIFICATE_SHA256_LABEL, maxsplit=1)[1] + if ACTUAL_SHA256_PATTERN.fullmatch(actual) is None: + raise ReleaseApkVerificationError("apksigner returned a malformed certificate SHA-256") + return actual.lower() + + +def require_expected_signer(apksigner_output: str, expected_sha256: str) -> str: + actual_sha256 = extract_single_signer_certificate_sha256(apksigner_output) + if actual_sha256 != expected_sha256: + raise ReleaseApkVerificationError( + "release APK signer certificate SHA-256 mismatch: " + f"expected {expected_sha256}, found {actual_sha256}", + ) + return actual_sha256 + + +def verify_release_apk( + apksigner: Path, + apk: Path, + certificate_sha256_path: Path = DEFAULT_CERTIFICATE_SHA256_PATH, +) -> str: + expected_sha256 = read_expected_certificate_sha256(certificate_sha256_path) + result = subprocess.run( + [str(apksigner), "verify", "--verbose", "--print-certs", str(apk)], + check=False, + capture_output=True, + text=True, + ) + if result.stdout: + print(result.stdout, end="" if result.stdout.endswith("\n") else "\n") + if result.stderr: + print(result.stderr, end="" if result.stderr.endswith("\n") else "\n", file=sys.stderr) + if result.returncode != 0: + raise ReleaseApkVerificationError(f"apksigner verify exited with {result.returncode}") + + actual_sha256 = require_expected_signer(result.stdout, expected_sha256) + print(f"Release APK signer certificate SHA-256 matches repository anchor: {actual_sha256}") + return actual_sha256 + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Verify an APK and require the repository-pinned production signing certificate.", + ) + parser.add_argument("apksigner", type=Path) + parser.add_argument("apk", type=Path) + parser.add_argument( + "--certificate-sha256", + type=Path, + default=DEFAULT_CERTIFICATE_SHA256_PATH, + ) + arguments = parser.parse_args() + try: + verify_release_apk( + arguments.apksigner, + arguments.apk, + arguments.certificate_sha256, + ) + except (OSError, ReleaseApkVerificationError) as failure: + parser.exit(1, f"Release APK verification failed: {failure}\n") + + +if __name__ == "__main__": + main() diff --git a/web/src/lib/particeps/bundle.ts b/web/src/lib/particeps/bundle.ts index 9411ccd..038fc53 100644 --- a/web/src/lib/particeps/bundle.ts +++ b/web/src/lib/particeps/bundle.ts @@ -66,7 +66,11 @@ const TRANSITION_DESTINATIONS: Record = { PARTICIPANT_RESUMED: 'RUNNING', PARTICIPANT_STARTED: 'RUNNING', PARTICIPANT_WITHDREW: 'WITHDRAWN', + COLLECTION_HOST_FAILURE: 'PAUSED', + COLLECTION_TEARDOWN_FAILURE: 'PAUSED', + REQUIRED_ACCESS_MISSING: 'PAUSED', STORAGE_FAILURE: 'PAUSED', + WORK_SCHEDULING_FAILURE: 'PAUSED', STUDY_DURATION_ELAPSED: 'COMPLETED' }; const STATE_TRANSITIONS: Record = { diff --git a/web/tests/bundle.spec.ts b/web/tests/bundle.spec.ts index fc599d1..a7a2c12 100644 --- a/web/tests/bundle.spec.ts +++ b/web/tests/bundle.spec.ts @@ -27,6 +27,120 @@ describe('PTCEXP01 Protocol v1 reader', () => { expect(canonicalize(JSON.parse(result.bundle.text))).toBe(result.bundle.text); }); + it('accepts required-access loss as a legal RUNNING-to-PAUSED transition', async () => { + const configuration = validConfiguration(); + const bytes = await sealBundle(configuration, SIGNING.privateKey, { + document: (value) => { + const changed = clone(value); + const transitions = changed.experiment.transitions; + const time = transitions[transitions.length - 1].time; + changed.experiment.state = 'PAUSED'; + transitions.push({ + from: 'RUNNING', + to: 'PAUSED', + reason: 'REQUIRED_ACCESS_MISSING', + time + }); + return changed; + } + }); + + const result = await openBundle(bytes, configuration, HPKE.privateKey); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bundle.document.experiment.state).toBe('PAUSED'); + const transitions = result.bundle.document.experiment.transitions; + expect(transitions[transitions.length - 1]).toMatchObject({ + from: 'RUNNING', + to: 'PAUSED', + reason: 'REQUIRED_ACCESS_MISSING' + }); + }); + + it('accepts collection-host failure as a legal RUNNING-to-PAUSED transition', async () => { + const configuration = validConfiguration(); + const bytes = await sealBundle(configuration, SIGNING.privateKey, { + document: (value) => { + const changed = clone(value); + const transitions = changed.experiment.transitions; + const time = transitions[transitions.length - 1].time; + changed.experiment.state = 'PAUSED'; + transitions.push({ + from: 'RUNNING', + to: 'PAUSED', + reason: 'COLLECTION_HOST_FAILURE', + time + }); + return changed; + } + }); + + const result = await openBundle(bytes, configuration, HPKE.privateKey); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bundle.document.experiment.transitions.at(-1)).toMatchObject({ + from: 'RUNNING', + to: 'PAUSED', + reason: 'COLLECTION_HOST_FAILURE' + }); + }); + + it('accepts collection teardown failure as a legal RUNNING-to-PAUSED transition', async () => { + const configuration = validConfiguration(); + const bytes = await sealBundle(configuration, SIGNING.privateKey, { + document: (value) => { + const changed = clone(value); + const transitions = changed.experiment.transitions; + const time = transitions[transitions.length - 1].time; + changed.experiment.state = 'PAUSED'; + transitions.push({ + from: 'RUNNING', + to: 'PAUSED', + reason: 'COLLECTION_TEARDOWN_FAILURE', + time + }); + return changed; + } + }); + + const result = await openBundle(bytes, configuration, HPKE.privateKey); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bundle.document.experiment.transitions.at(-1)).toMatchObject({ + from: 'RUNNING', + to: 'PAUSED', + reason: 'COLLECTION_TEARDOWN_FAILURE' + }); + }); + + it('accepts work scheduling failure as a legal RUNNING-to-PAUSED transition', async () => { + const configuration = validConfiguration(); + const bytes = await sealBundle(configuration, SIGNING.privateKey, { + document: (value) => { + const changed = clone(value); + const transitions = changed.experiment.transitions; + const time = transitions[transitions.length - 1].time; + changed.experiment.state = 'PAUSED'; + transitions.push({ + from: 'RUNNING', + to: 'PAUSED', + reason: 'WORK_SCHEDULING_FAILURE', + time + }); + return changed; + } + }); + + const result = await openBundle(bytes, configuration, HPKE.privateKey); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bundle.document.experiment.transitions.at(-1)).toMatchObject({ + from: 'RUNNING', + to: 'PAUSED', + reason: 'WORK_SCHEDULING_FAILURE' + }); + }); + it('uses one JCS context for HPKE info and content AAD', () => { expect( new TextDecoder().decode(