Skip to content

Ask the strap to LIST its device-config keys before guessing any: a read-only 115/116 enumeration probe, with a derived key-name sweep as the fallback (#103) - #898

Open
vishk23 wants to merge 4 commits into
ryanbr:mainfrom
vishk23:feat/whoop5-config-key-sweep

Conversation

@vishk23

@vishk23 vishk23 commented Jul 27, 2026

Copy link
Copy Markdown

Read-only follow-up to #890. That probe asked a strap for one config key's VALUE, and on a WHOOP 5 MG (WS50_r03) both read verbs answered — the first time either has. This one acts on what the answer turned out to be.

1. The reply is an existence oracle

GET_FF_VALUE (128) and GET_DEVICE_CONFIG_VALUE (121) answer differently for a key name the firmware has and one it does not:

reply meaning
result = SUCCESS(1) the key exists in that namespace (and a value byte comes with it)
result = FAILURE(0) the firmware has no key by that name

One round-trip is therefore a key-existence test: read-only, cheap, decisive. That turns "does a config key gate SpO2?" from an unanswerable question into a finite search over key names.

ConfigKeySweep.Existence is that mapping, and it reads the result code and nothing else. UNSUPPORTED(3), any other code, and WHOOP 4.0's unlabelled result byte are all inconclusive — never folded into either answer.

Values read on that strap, for the record: the sixteen R22 flags read back as written, and the Broadcast-HR device-config key whoop_live_hr_in_adv_ind_pkt (the #181 key NOOP already writes, and #890's known-good control) read back '0' — the first time its value has been observed rather than only set.

2. But guessing is the fallback, not the plan — 115/116 first

The CommandNumber table in this repo's own whoop_protocol.json names two symmetric config namespaces, four verbs each:

feature-flag    117 START_FF_KEY_EXCHANGE            118 SEND_NEXT_FF
                120 SET_FF_VALUE                     128 GET_FF_VALUE
device-config   115 START_DEVICE_CONFIG_KEY_EXCHANGE 116 SEND_NEXT_DEVICE_CONFIG
                119 SET_DEVICE_CONFIG_VALUE          121 GET_DEVICE_CONFIG_VALUE

#872 built the feature-flag enumerate pair and a strap answered it, listing the sixteen keys Whoop5Config.enableR22Sequence writes. #890 built both value reads and the same strap answered those. The device-config enumerate pair — 115/116 — has never been sent by anything here, and it is the structural twin of the pair that already works.

If 115/116 answer, the strap simply hands over its own device-config key list: no dictionary, no morphology, no sweep. So this probe asks first and guesses second, and when enumeration produces a list the candidate sweep is skipped entirely and the report says so.

A clean "115/116 are not served" is a useful, publishable result — it is exactly what promotes the guessing fallback from a shortcut to the only available method, and it costs one round-trip to establish.

115/116 are decoded by FeatureFlagProbe.parseStart/parseNext with the opcode passed in, i.e. on the assumed-symmetric 117/118 record layout. That is an inference from the naming symmetry in this repo's own table, not an observation, and it fails closed: a mismatch surfaces as a short record or an implausible count and retires the walk with a named reason rather than inventing key names. The walk inherits @ryanbr's #874 discipline — the strap's own end marker terminates it, but an entry whose NAME does not decode is counted and stepped over, so one bad entry cannot throw away every key after it.

3. The rest of the plan

In order, each phase skipped when the verb that would carry it is dead:

  1. enumerate — 115, then 116 until the strap says stop.
  2. discovery — one 128 read against a flag NOOP writes, one 121 read against the Broadcast-HR key, so a FAILURE is evidence about the verb, not the key.
  3. cross-namespace — ask each verb for the OTHER namespace's known-good key. Two round-trips that settle whether the namespaces are genuinely separate; if one verb turns out to serve both, everything afterwards goes through it and every future sweep halves.
  4. known key values — the sixteen flags, plus anything enumeration returned.
  5. candidate sweep — and only when enumeration produced nothing.

A verb that is refused, silent or undecodable is retired, so a dead verb costs one 8 s window rather than one per key.

4. Why a derived candidate list beats free association

Eight plain-English oxygen names (enable_spo2, enable_blood_oxygen, enable_pulse_ox, …) were asked of a real MG and all eight came back FAILURE. They now live in ConfigKeySweep.retiredKeys — out of the sweep so nobody spends round-trips re-asking, in the file so nobody proposes them again. That all eight are product English, and all eight wrong, is the argument for deriving names from the firmware's own words.

ConfigKeySweep.catalogue is that derivation. Every entry is a cross-product of two things already in this repo:

A — morphology, the templates the seventeen confirmed key names follow:

T1  enable_<subsystem>_packets          enable_r22_packets
T2  enable_<rev>_v<N>_packets           enable_r22_v2_packets … enable_r22_v8_packets
T3  disable_<subsystem>_<rev>_packets   disable_pip_r26_packets
T4  make_<subsystem>_visible            make_hrfm_visible
T5  <signal>_<domain>_switching         hr_ch_switching, ir_hw_switching
T6  <thing>_detect_bias                 wear_detect_bias
T7  enable_<feature>_gen5               enable_passive_strap_fit_gen5
T8  enable_sig<N>[_during_sleep]        enable_sig11_during_sleep, enable_sig12
T9  <codename>_inhibit_<abbrev>         dorset_inhibit_wpt
T10 whoop_<metric>_in_<transport>       whoop_live_hr_in_adv_ind_pkt   (device-config)

B — vocabulary, the tokens the firmware uses about itself. Two in-repo sources, no others: the CommandNumber table (optical 107/108, labrador 124/125/139, research 131/132, afe 61/62, raw 81/82, and the revision tokens r7 16, r10/r11 63, r20/r21 153/154), and the strap's own plaintext console log, whose subsystem tags this package already documents from real captures.

One console line does most of the work here:

SIGPROC: generated a valid SPO2 during sleep

That is the firmware describing its own SpO2 computation, under the tag SIGPROC, using the exact during sleep phrasing the confirmed key enable_sig11_during_sleep uses. If sig is SIGPROC, the sig<N> number line is the likeliest home for an oxygen gate — which is why it is the catalogue's largest family, and why walking that number line requires guessing nothing at all: it just asks which N exist.

54 candidate names, in eight families: sig number line (16), r22 version gaps (4), revision slot (8), optical/AFE (6), labrador/ECG (5), research + high-rate (4), SIGPROC + oxygen (7), device-config namespace (4).

The single entry I would watch first is enable_r22_v7_packets. NOOP writes v2, v3, v4, v5, v6 and v8 — v7 is a hole in an otherwise contiguous observed series. A SUCCESS there proves the oracle finds keys NOOP does not already know, on the first run, and makes every other family worth extending.

They are still guesses, labelled as such in the constant, in the report section header, and here.

5. A fully-negative sweep is a result

If all 54 come back unknown, the verdict says so in those words — "asked 54 candidate key name(s); this firmware has none of them (a clean negative)" — and that rules out eight whole families of names, not just 54 strings. Combined with a negative on 115/116 it is worth publishing: it closes off the "a client-writable config key gates Blood Oxygen" branch of #103 much harder than any individual guess could.

Contributions welcome, and cheap. ConfigKeySweep.catalogue is the one place to edit; adding an entry adds one round-trip and nothing else. Say which template and which token it comes from, and add the same line to the Kotlin twin — a unit test asserts the two lists are identical, so a one-sided edit fails in CI rather than on someone's strap. If you have run the probe, the interesting thing to post is the report's Verbs: block and the Namespace separation: verdict; those two shape what everyone else should sweep next.

6. Read-only by construction, and testably so

DeviceConfigReadProbe.readOnlyOpcodes is {115, 116, 121, 128}, and isReadOnlyOpcode is the same predicate the 5/MG send() allowlist consults — admitting them only while a probe is actually in flight, so a default install can never form these bytes. There is no parallel copy to drift.

  • allowlistAdmitsOnlyTheFourReadVerbs — the set is exactly those four
  • allowlistRejectsBothConfigWriteVerbs — 119 and 120 (and Whoop5Config.setConfigCmd / setDeviceConfigCmd) rejected
  • allowlistRejectsEveryOtherOpcodeall 252 remaining opcodes rejected, counted, including the destructive numbers (25 FORCE_TRIM, 29 REBOOT_STRAP, 32 POWER_CYCLE_STRAP)

The SET verbs keep their own separate opt-in clauses, untouched. Per-step timeout is 8 s and terminates cleanly on no-reply; the plan is capped at 128 round-trips; one run is bounded at maxKeysPerRun names and reports its arithmetic ("N asked of M in the catalogue; K untested … run again to continue from entry X") so a catalogue grown past the budget truncates visibly instead of silently.

Gated behind Test Centre → Connection like the #592/#690/#872/#890 probes, user-triggered from the Devices strap menu, result to a copyable dialog + the strap log. No new storage, no migration, no new strings.

7. Shape

Parsing, the plan and the report rendering live in the pure WhoopProtocol package with Kotlin twins, so the whole probe is covered without a strap:

  • Packages/WhoopProtocol/Sources/WhoopProtocol/ConfigKeySweep.swift (new — oracle, enumeration verbs, catalogue, batching)
  • Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift (the plan + report)
  • android/app/src/main/java/com/noop/protocol/ConfigKeySweep.kt
  • android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt

FeatureFlagProbe.parseStart / parseNext gain an expecting: parameter defaulting to 117/118 — additive, so #872's callers are unchanged.

Golden assertions on both platforms: the full report text byte-for-byte, and the catalogue as one pinned string, so a wording drift or a one-sided list edit fails in tests rather than in a user's log.

Verification

  • swift test (WhoopProtocol) — 436 passed / 0 failures (417 before; +16 new in ConfigKeySweepTests, and DeviceConfigReadProbeTests rewritten around the new plan, 25 → 28)
  • Android testFullDebugUnitTest3208 tests, 1 failure, 5 skipped (+19 new: ConfigKeySweepTest 16, DeviceConfigReadProbeTest 25 → 28). Counts read from the JUnit XML, not the exit code. Run locally; Android CI does not run on PRs.
    • The one failure is com.noop.data.DeepCaptureMigrationTest.repositoryInsertV18Aux_insertsThenPrunes (expected:<my-whoop> but was:<null>), which is pre-existing and unrelated: it fails identically on a clean upstream/main at 2b21ae0 with none of this branch applied, and nothing in this diff touches com.noop.data, any migration, or WhoopStore.
  • Strand (macOS) builds locally — ** BUILD SUCCEEDED ** (app-build.yml is disabled)
  • Tools/i18n_audit.py --ci and Tools/doc_comment_lint.py clean. No new strings, so no translation work.
  • UNVERIFIED on hardware: nothing in this project has ever had 115 or 116 answered. That is exactly what the probe exists to establish, and either answer is worth having.

…ead-only 115/116 enumeration probe, with a derived key-name sweep as the fallback (#103)

Read-only follow-up to #890. On a WHOOP 5 MG (WS50_r03) both of that PR's read
verbs answered, and the reply turned out to be an existence oracle: a key name
the firmware has answers result=SUCCESS(1), one it does not answers FAILURE(0).
One round-trip is therefore a key-existence test, which makes a finite search
over key names possible for the first time.

But guessing is the fallback, not the plan. The CommandNumber table in this
repo's own whoop_protocol.json names a device-config ENUMERATION pair nothing
here has ever sent — 115 START_DEVICE_CONFIG_KEY_EXCHANGE and 116
SEND_NEXT_DEVICE_CONFIG — the structural twin of the 117/118 feature-flag pair
#872 built and a strap answered. If they answer, the strap hands over its own
device-config key list and no name needs guessing: the candidate sweep is
skipped and the report says so. A clean "115/116 are not served" is equally
useful, and is what promotes guessing from a shortcut to the only method.

115/116 are decoded by FeatureFlagProbe.parseStart/parseNext with the opcode
passed in, on the assumed-symmetric 117/118 record layout — an inference from
the naming symmetry in this repo's own table, not an observation. It fails
closed, and the walk inherits #874's discipline: the strap's own end marker
terminates it, but an entry whose name does not decode is counted and stepped
over rather than treated as the end.

The plan then reads the values of keys already known to exist, spends two
cross-namespace round-trips establishing whether 121 and 128 really serve
separate namespaces (if one serves both, everything afterwards goes through
it), and only then asks the guessed names.

ConfigKeySweep.catalogue is derived, not free-associated: every one of its 54
names is a cross-product of the morphology of the seventeen confirmed keys
(enable_<subsystem>_packets, make_<subsystem>_visible, enable_sig<N>
[_during_sleep], whoop_<metric>_in_<transport>, and six more) with the
vocabulary the firmware uses about itself — the CommandNumber table's optical
(107/108), labrador (124/125/139), research (131/132), afe (61/62) and its
revision tokens r7/r10/r11/r20/r21, plus the strap's own console-log subsystem
tags. One of those tags does most of the work: "SIGPROC: generated a valid SPO2
during sleep" pairs the firmware's SpO2 computation with the same "during
sleep" phrasing the confirmed key enable_sig11_during_sleep uses, which is why
the sig<N> number line is the catalogue's largest family. The eight
plain-English oxygen names a real MG already answered FAILURE move to
retiredKeys — out of the sweep, kept in the file so nobody proposes them again.

Read-only by construction: readOnlyOpcodes is {115, 116, 121, 128} and
isReadOnlyOpcode is the same predicate the 5/MG send() allowlist consults,
admitting them only while a probe is in flight. Tests assert 119, 120 and all
252 other opcodes are rejected. Per-step timeout retires a dead verb after one
window; the plan is capped at 128 round-trips; one run is bounded and reports
its own tested/untested arithmetic so a grown catalogue truncates visibly.

Gated behind Test Centre -> Connection like the #592/#690/#872/#890 probes. No
new storage, no migration, no new strings.

swift test: 436 passed / 0 failures. Android testFullDebugUnitTest: 3208 tests,
5 skipped, and 1 failure — com.noop.data.DeepCaptureMigrationTest, which fails
identically on a clean upstream/main and which nothing here touches.
…ong body shape as a clean negative (#103)

Two verdict lines on this branch made positive claims from runs that had
established nothing. Both are the same class of defect as the one just fixed
in Whoop5EcgProbe (#896): a null read as a result.

1. "the strap enumerated its device-config namespace and returned no key NOOP
   did not already have" fired on `enumerationVerb == .answered` alone, and
   `noteEnumerationStart` treated ONLY result 3 as a refusal. So a strap that
   answered START_DEVICE_CONFIG_KEY_EXCHANGE(115) with FAILURE(0) and a zeroed
   record parsed fine (count = 0), the 116 follow-up decoded as exhausted, the
   walk ended with zero names — and the report asserted a successful, complete
   enumeration of a namespace nobody had listed. The same string also fired
   when the walk was TRUNCATED at maxEnumerationSteps (the cap went to
   stopReason only) and when every name had been enumerationSkipped, i.e. when
   OUR parser was the thing that failed.

   Now only SUCCESS(1) — or WHOOP 4.0, where the result byte's meaning has
   never been pinned here — opens a walk; 0 and 2 land on a new `inconclusive`
   status and 116 is never asked. The completeness claim additionally requires
   a walk that actually ran, reached the strap's own end marker, was not capped,
   and whose entries all decoded, with a distinct wording for each way that
   fails. The skipped>0 branch names our parser rather than the strap — the
   #874 discipline FeatureFlagProbe.verdict already carries, which this walk
   inherits along with its parser.

2. "this firmware has none of them (a clean negative)" fired on
   `unknown == asked` without checking that the run's calibration control had
   answered. ConfigKeySweep.existence maps FAILURE(0) to "no key by this name",
   but ValueResponse.isFailure documents the same code as "the verb exists, the
   request did not satisfy it (wrong body shape, or an unknown key)" — and the
   request body is inferred from the SET side, never observed. Since setStatus
   marks a verb `answered` for any non-UNSUPPORTED reply, a run in which the
   known-good control key `whoop_live_hr_in_adv_ind_pkt` (#181) itself returned
   FAILURE still reached "a clean negative", publishing our own body shape as
   the non-existence of a whole derivation family of names.

   The wording is now gated on `oracleCalibrated`: at least one .exists among
   the discovery/knownKey readings of the SAME run. Without it the verdict says
   the oracle is uncalibrated and the run is inconclusive.

Ported to the Kotlin twin; the verdict strings stay byte-identical across
platforms. The existing golden reports are unchanged — that scenario refuses
115 as UNSUPPORTED and its control answers SUCCESS, so neither gate moves it.

Tests: ten new cases per side covering the FAILURE(0) and PENDING(2) replies to
115, the WHOOP 4.0 nil-result path, a walk that listed nothing, a walk whose
names all failed our parser, a capped walk, a walk with no end marker, the
uncalibrated-oracle sweep that was previously untested, and — as the guard
against over-correcting — the completed walk and the calibrated sweep that must
keep their original wording.

Swift 446 tests / 0 failures. Android 3218 tests / 1 failure, the pre-existing
DeepCaptureMigrationTest.repositoryInsertV18Aux_insertsThenPrunes (#897).

Not touched here: the `answered == 0` "neither GET_FF_VALUE(128) nor
GET_DEVICE_CONFIG_VALUE(121) is served by this firmware" fallback, which is
verbatim identical on main and is being fixed separately on
fix/config-read-probe-verdict-honesty. Fixing it here too would collide.
@vishk23

vishk23 commented Jul 28, 2026

Copy link
Copy Markdown
Author

Pushed 4e47645 — two of this branch's own verdict lines were making positive claims from runs that had established nothing. Both are the same class of defect as the one fixed in #896: a null read as a result.

1. A refusal read as a completed enumeration

"the strap enumerated its device-config namespace and returned no key NOOP did not already have" fired on enumerationVerb == .answered alone, and noteEnumerationStart treated only resultCode == 3 as a refusal. Everything else — including FAILURE(0) and PENDING(2) — set .answered.

So a strap answering START_DEVICE_CONFIG_KEY_EXCHANGE(115) with FAILURE(0) and a zeroed record parsed fine (count = 0), the 116 follow-up decoded as exhausted, the walk ended with zero names, and the report asserted a successful, complete enumeration of a namespace nobody had listed. Given #103's actual question — does a config key gate SpO2 — that verdict is worse than no verdict: it retires the enumeration path on evidence that the verb declined every question.

The same string also fired when the walk was truncated at maxEnumerationSteps (the cap went to stopReason only, never to the verdict) and when every entry was enumerationSkipped, i.e. when our parser was the thing that failed. That last branch is the #874 discipline FeatureFlagProbe.verdict already carries at FeatureFlagProbe.swift:381-385; noteEnumerationNext claims to mirror it but the verdict never did.

Now:

  • Only SUCCESS(1) — or WHOOP 4.0, where the result byte's meaning has never been pinned here — opens a walk. 0 and 2 land on a new inconclusive verb status and 116 is never asked. A declined start also no longer records the announced count, which it had not earned the right to report.
  • The completeness claim additionally requires a walk that ran, reached the strap's own end marker, was not capped, and whose entries all decoded — each failure getting its own wording rather than one blanket assertion.

2. A wrong body shape read as a clean negative

"asked N candidate key name(s); this firmware has none of them (a clean negative)" fired on unknown == asked with no check that the run's calibration control had answered.

ConfigKeySweep.existence maps FAILURE(0).unknown = "no key by this name", but ValueResponse.isFailure in the same file documents that code as "the verb exists, the request did not satisfy it (wrong body shape, or an unknown key)" — and the file header records the request body as inferred from the SET side, not observed. Those two readings are only separable by a control.

The control exists: the discovery read of whoop_live_hr_in_adv_ind_pkt, hardware-validated in #181. The verdict never consulted it. Since setStatus marks a verb answered for any non-UNSUPPORTED reply, a run in which the known-good control itself returned FAILURE(0) still ended at "a clean negative" — publishing our own body shape as the non-existence of an entire derivation family of names.

The wording is now gated on oracleCalibrated: at least one .exists among the .discovery/.knownKey readings of the same run. Without it the verdict says the oracle is uncalibrated and the run is inconclusive.

testAFullyNegativeSweepIsACleanNegativeVerdict only ever exercised the calibrated case — its driveToCandidates helper feeds resultCode: 1 to every non-candidate step — so the uncalibrated path was untested rather than prevented. That helper now takes a control parameter and both paths are pinned.

Tests

Ten new cases per side: the FAILURE(0) and PENDING(2) replies to 115, the WHOOP 4.0 nil-result path (which must still open the walk), a walk that listed nothing, a walk whose names all failed our parser, a capped walk, a walk with no end marker, the previously-untested uncalibrated sweep, and — as the guard against over-correcting — the completed walk and the calibrated sweep, which must keep their original wording verbatim.

  • swift test446 tests, 0 failures
  • ./gradlew testFullDebugUnitTest3218 tests, 1 failure, that being the pre-existing DeepCaptureMigrationTest.repositoryInsertV18Aux_insertsThenPrunes (fix(android): repositoryInsertV18Aux_insertsThenPrunes has been red on main since #888 #897), confirmed from test-results/**/*.xml as the only one
  • DeviceConfigReadProbe tests: 38 per side, both golden reports unchanged — that scenario refuses 115 as UNSUPPORTED and its control answers SUCCESS(1), so neither new gate moves it

Deliberately not fixed here

The answered == 0 fallback — "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this firmware" — has the same over-claiming shape (a silent/undecodable pair is not proof the verbs are unimplemented), but it is verbatim identical on main and is being fixed separately. Duplicating the fix on this branch would collide with that work, so it is left untouched here.

vishk23 added 2 commits July 27, 2026 22:49
This branch has the pre-fix fallback verbatim — 4e47645 called it out and left
it alone deliberately — so merging would put the over-claim back after it is
fixed on main by fix/config-read-probe-verdict-honesty. Same change, adapted to
the verbs and statuses this branch has.

The no-answer verdict printed "neither GET_FF_VALUE(128) nor
GET_DEVICE_CONFIG_VALUE(121) is served by this firmware" for two timeouts (the
send path can return without transmitting at all), for one refusal plus one
timeout (the `||` spoke for a verb that was never heard from), and for an
undecodable reply (which is affirmative evidence the strap DID transmit).

Each verb now reports the evidence it produced: "refused by firmware
(UNSUPPORTED)", "served no reply in Ns — unconfirmed", "replied but the frame
did not decode — unconfirmed", "not asked". noteTimeout records the window so
the verdict can name it. The strong "not served by this firmware" sentence is
kept only when the firmware refused BOTH value verbs itself — the `||` is now
an `&&` — and it names only the two verbs it speaks about.

`inconclusive`, added by 4e47645, gets "replied but declined the request —
unconfirmed". Like `undecodable` it is the strap ANSWERING, so it is evidence
the verb exists and must not be worded like a verb never heard from.

The enumeration verb is named in the per-verb sentence too, since on this
branch 115/116 is the headline read verb. It spans two opcodes, so `outcome`
takes the list of commands a verb can ride on rather than a single one.

swift test 452 passed, 0 failures; gradlew testFullDebugUnitTest 3224 tests,
1 failure — DeepCaptureMigrationTest.repositoryInsertV18Aux_insertsThenPrunes,
pre-existing on this branch's base and fixed by #897 on main — verified from
the JUnit XML (390 files, one mtime).

Refs #103.
Upstream #914 ("stop the config-read probe reporting silence as a firmware
verdict") rewrote the same four files this branch rewrites, and is the ONLY
upstream commit to touch any of them since the merge base — verified with
`git log <base>..upstream/main -- <the four files>`.

Resolved to this branch's version of all four, which is a strict superset rather
than a competing edit:

  * All three tests #914 added — testAProbeThatAskedNothingClaimsNothing,
    testAnUndecodableReplyIsNotReportedAsUnserved, and
    testOneRefusalIsNotGeneralisedToTheVerbThatWasNeverHeardFrom — are already
    present here on both platforms, adapted for the two extra verbs this branch
    adds (115/116 enumeration), which is why their bodies differ.
  * The eleven identifiers that exist upstream and not here are the same eleven
    this branch renamed against the merge base, one-for-one on Swift and Kotlin —
    e.g. allowlistAdmitsOnlyTheTwoReadVerbs -> ...OnlyTheFourReadVerbs,
    bothVerbsUnsupportedEndsTheProbeAfterTwoRoundTrips ->
    bothValueVerbsRefusedKeepsTheStrongSentence. Nothing upstream added was
    dropped; the parity between the Swift and Kotlin renames is preserved.
  * #914's rule is kept verbatim in substance: only UNSUPPORTED is the firmware's
    own answer, a timeout is a fact about one run, and an undecodable reply is
    affirmative evidence the strap transmitted. This branch states it more tightly
    because it now has a measured result to state it against — both read verbs
    ANSWERED on a real WS50_r03, and the reply is an existence oracle.

`outcome(_:for:)` takes an opcode ARRAY here (the enumeration verb rides two
opcodes, 115 and 116), which is why the verdict hunks conflicted rather than
merging. docs/PROTOCOL.md merged cleanly.

Verified: WhoopProtocol 473/473, android `./gradlew testFullDebugUnitTest`
3263 tests / 0 failures (5 skipped).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant