Skip to content

[WIP] rework GroveHealthKitFHIR - #118

Draft
lukaskollmer wants to merge 179 commits into
lukas/fhir-review-fixesfrom
lukas/grove-fhir-api-changes
Draft

lukaskollmer wants to merge 179 commits into
lukas/fhir-review-fixesfrom
lukas/grove-fhir-api-changes

Conversation

@lukaskollmer

@lukaskollmer lukaskollmer commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

♻️ Current situation & Problem

#67 replaced the previous HealthKit → FHIR conversion layer (which simply produced FHIR Observations with informal and practically undocumented and non-guaranteed semantics and structure) with a new HealthKit → FHIR API whose output is in compliance with our implementation guide

however, this new API was cumbersome to use and understand, and has significantly worse performance (e.g. w.r.t. HKSample → FHIR resource conversion throughput) than what it was replacing.

this PR aims to rework it again, in a way that keeps the fundamental structure and most of the implementation in place, but simplifies and streamlines the public API around the new conversion layer, and also optimizes the implementation to (vastly) improve performance, to a level that at least matches (and in the best case will hopefully exceed) that of the original implementation both PRs intend to replace.

terminology:

new high level API

instead of the TODO TOD OTODO

// 1. Once per installation and account: the producer owns identities and the ledger.
let producer = try ExchangeProducer(
    identityScope: identityScope,          // HMAC key + deployment identifier systems
    subject: subject,                      // the participant (logical or bundled Patient)
    application: application,              // ApplicationDevice snapshot of the app
    host: .current(),
    studies: enrollments,                  // [StudyEnrollment]
    storage: ledgerStorage                 // any ExchangeProducer.Storage
)

// 2. One long-lived exporter on top of it (Sendable, supports concurrent multithreaded usage).
var options = HealthKitFHIRExporter.Options()   // every disclosure defaults to omission
options.nativeIdentifier = .authorized(system: myNativeRecordSystem)
options.maximumConcurrency = nil                // nil = every core; 1 = serial
options.measuresThroughput = false              // opt-in timing, read via exporter.throughput
let exporter = try HealthKitFHIRExporter(
    producer: producer,
    repositoryScope: repositoryScope,
    options: options
)

// 3. Export a batch: one ledger transaction reserves every event, graphs are built concurrently,
//    and propagated back to the caller in input order (on the caller's task)
var report = HealthKitFHIRExporter.WarningReport()
let receipt = try await exporter.export(samples, at: .now) { export in
    report.add(export)
    switch export.outcome {
    case .graph(let graph): store(graph.json)           // validated ExchangeGraph; can be uploaded verbatim
    case .refused(let error): log(error.diagnostic)      // typed, per record; the call continues
    }
}
logger.info("\(report)")
receipt.release()   // only after the bytes are durable; until then a retry reproduces every event byte for byte

// 4. Deletions become retraction graphs the same way.
let retractionReceipt = try await exporter.retract(deletions, at: .now) { retraction in
    // .graph(ExchangeGraph) | .refused(HealthKitConversionError) | .nothingToRetract
}

performance

impl per sample (single core) samples/s (single core) per sample (18 cores) samples/s (18 cores)
A 94.3 µs 10,602 27.4 µs 36,514
B 3,152.2 µs 317 510.7 µs 1,958
C 426.2 µs 2,346 76.8 µs 13,026
C* 219.2 µs 4,562 51.7 µs 19,338
  • measured on an M5 Pro, 5000 heart-rate samples with Watch device and study context
  • C*: output validation disabled

overall, A still is by far the most performant implementation: this is because it doesn't perform any validation, and because its output (FHIR Observations) is much smaller and simpler than B and C's output (FHIR Bundles containing a graph of Observations and other, related/referenced data)

⚙️ Release Notes

todo

📚 Documentation

yes

✅ Testing

yes

Code of Conduct & Contributing Guidelines

By creating and submitting this pull request, you agree to follow our Code of Conduct and Contributing Guidelines:

lukaskollmer and others added 30 commits October 3, 2026 10:34
Parse each Bundle's JSON once and share the parsed document across every
validation pass instead of re-encoding resources per pass; drain Foundation
temporaries per graph. Move the plain-decimal formatter into String+Utils
with a Foundation-free `isBlank`, and nest `ApplicationDeviceError` as
`ApplicationDevice.InitError`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Add a test-only factory for stored HealthKit samples with chosen UUIDs,
source revisions and devices, and pin the converter's output for 40
shapes (observations, writers, disclosures, documents, retractions) as
checked-in goldens compared with lossless-token equality. Check in the
conversion throughput benchmark, gated behind GROVE_FHIR_BENCH_RUN.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Expose each exchange graph's validated bytes (sorted keys, unescaped
slashes) and a validating initializer for received bytes, nest the graph
kind, and add the shared producer configuration, the Grove-owned event
sequencer with app-supplied storage and keyed in-flight reservations, a
one-call identity-scope initializer, and an ASCII millisecond instant
formatter. The earlier spellings stay as deprecated forwards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A long-lived `HealthKitFHIRExporter` replaces the per-sample context plumbing of `HealthKitConverter`: it is configured once from an `ExchangeProducer`, a repository scope and `Options`, mints every event through the producer's `ExchangeEventSequencer` (so an exact redelivery before `Receipt.release()` reproduces the same bytes), converts samples, companion records (ECG, heartbeat series, workout route) and deletions in input order, and reports each outcome as a validated graph, a refusal diagnostic or `nothingToRetract` without ending the call.

Options express the deployment's policies once: `WriterPolicy.automatic` classifies Apple's per-device sources (`com.apple.health.<device>`) as the physical device that recorded the sample and every other source as an application; a `.device` writer without a stable `HKDevice` token now derives the recording Device from that source (name, `Apple Inc.`, product type) instead of omitting the author; `.omit` writers and recording devices state nothing and never warn; `legacyBundleID = .healthKitUUID` keeps the uppercase HealthKit UUID in `Bundle.id` for receivers that still key on it (deprecated at introduction). A native identifier under one of the deployment's own systems is refused at configuration.

The exporter is a facade over the existing conversion code: every graph is byte-identical to the old entry point's under the same event (pinned by the goldens and the new exporter tests). The `HealthKitConverter` public entry points stay as a deprecated shim for the existing tests and consumers; their bodies moved into internal statics the exporter calls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A refused record reports its `HealthKitConversionError` rather than only the registered diagnostic, so a consumer can match the reason; a retraction carries the same legacy `Bundle.id` as the addition it retracts; a deletion lower bound that a backwards clock adjustment put after the detection is dropped instead of clamped; and an export or retraction with nothing to reserve never touches the sequencer's ledger.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The graph envelope (output identities and links, study context, device snapshots, writer deduplication, conversion Provenance, Bundle framing) moves into a package-level `ExchangeGraphAssembler` in GroveFHIRContract. An adapter describes one event as an `ExchangeGraphDraft`: content-only outputs with the envelope links each takes, the resolved recording device and writer, and the event's role and repository ids. The assembler mints every identity once, computes each fullUrl once, and builds the graph in the guide's fixed entry order.

On the HealthKit side, `HealthKitAssembly` replaces the static `HealthKitConverter` pipeline: `SourceFacts` resolves what a sample says about its origin (recording device, writer, native and writer-record identifiers, withheld metadata) in one place, and the content builders (Observation, workout segments, ECG and its average heart rate, recording documents) no longer reach into an envelope. The old envelope code (`GraphEnvelope`, `GraphChildOutput`, `HealthKitGraphContext`, the device and sync-identity helpers) is deleted. `HealthKitFHIRExporter` converts through the assembly directly; the deprecated `HealthKitConverter` entry points forward to it until they are removed.

Output is unchanged: the 40 golden graphs match byte for byte. Tests that exercised the deleted internal seams reach the same behaviour through a test-only seam file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes from the verified step-6 specifications that live in the envelope, the contract and the exporter:

- Workouts export at all (F1). Every HKWorkout conversion used to be refused: its segment children claimed only grove-mobile-workout-segment, which the HealthKit conversion Provenance does not govern. The pinned guide defines no HealthKit segment output, so a workout now exports its session alone, under output role workout and discriminator single, with the full graph context. Its events and activities are withheld (field dispositions say so), segment construction and the assembler's hasMember hook are deleted, and the retraction of a workout names exactly that session output. New goldens: workout-session, retraction-workout.
- Document graphs under a distinct gateway application (F2) no longer carry a gateway Device that nothing references (mobile-support.connected); the guide defines the gateway link for Observations only.
- Event instants (F7) — Bundle.timestamp, Provenance.recorded and occurred, DocumentReference.date and every retraction instant — are written by ExchangeInstant: UTC, millisecond precision, ASCII digits, no binary64 noise. A retraction start before 0001-01-01 is stated as that instant. Whole-second instants are byte-identical.
- A recording document carries no writer-record identifier (F10, part 1): the version that must travel with it has no carrier on a DocumentReference. This undoes a regression the shared assembler introduced; the sync pair is still validated.
- The validator applies the kit's whole adapter-provenance-graph rule (F11): every adapter output is covered by exactly one conversion Provenance of its adapter, checked last as the kit does.
- RolePolicy.gatewayForOwnWrites (F3) states the converting application as gateway only for samples written by the build it runs (bundle identifier and HKSourceRevision.version equal to its build). Merging a self-written sample's writer Device into the converter's needs a guide change first: the pinned mapping requires the writer to state the revision version itself.
- Retraction (F8, simple policy): a workout-route deletion is retracted only while Options.route is .authorized; an undisclosed route was never exported, and retracting it would disclose that it existed. Outcomes now arrive in input order.

All 40 earlier goldens are byte-identical; SensorKit and Questionnaire suites pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Implements the Grove part of the exchange-ledger design (ledger-design/final-spec.md
sections (a), (b), (d) and the Grove tests of (f), steps 1 to 3 and the ledger half of
step 5), except the writer policy, which is the next step.

Storage and sequencer (GroveFHIRContract):
- ExchangeEventSequencer.Storage is a backend-independent, transactional string-to-bytes
  map (Storage.transaction, Transaction.read/write/remove/keys(prefixedBy:)) with the
  C1-C5 contract documented in the new ExchangeLedgerStorage article. InMemoryStorage is
  the public reference implementation; inMemory() uses it.
- The ledger keeps three versioned JSON entry kinds: `producer`, `event/<key>` and
  `facts/<digest>`. Every read is validated and faults surface as typed LedgerError
  (unsupportedEntryVersion, corruptEntry), never a trap; reset() always recovers.
- reserve(_:at:facts:) is one transaction over its own keys: a key whose stored
  fingerprint matches returns its instance, sequence, instant and frozen facts and
  writes nothing; any other request takes the next sequence (sorted request order)
  under the current facts. The producer instance is returned with every reservation,
  and a counter that would overflow mints a new instance.
- Holds are tracked in process memory (HoldRegistry); finish(_:released:forgetting:)
  removes a reservation only when its last live holder finishes and some holder
  released it, and only while the key still holds that exact (key, instance,
  sequence). Nothing is pruned implicitly; forgetReservations(madeBefore:) is the
  explicit maintenance call.
- Deleted: State, Retention, StateError, the producerInstance getter,
  releaseIgnoringErrors, the sequencer lock and the reservation's Codable conformance.
- ExchangeEventFacts (application, host, studies) are stored once per distinct value,
  decoded through the validating initializers, and every graph is built from the
  decoded entry, first delivery and redelivery alike. ExchangeEnvelope carries them
  and swaps them per event (with(_:)); the HealthKit assembly builds its assembler
  per request facts.
- OpaqueIdentityScope.ledgerFingerprint, ExchangeGraphAssembler.outputRevision and
  HealthKitAssembly.outputRevision (both 1) feed the exporter's context fingerprint.

Exporter (GroveHealthKitFHIR):
- One reserve transaction per export or retraction call. Requests carry an exhaustive
  context fingerprint (output revisions, identity scope, subject, repository scope,
  every option by exhaustive switch) plus record parts (an ECG's sorted symptom UUIDs,
  a retraction's clamped bounds), so a changed context never reuses an identifier.
- ECG symptoms are paired with their reservations by request, not by position.
- Nothing is released mid-call: refusals and policy omissions keep their reservations
  until the receipt is released.
- Receipt is a final class that releases at most once (also across copies), in one
  transaction, none when empty; dropped unreleased it lapses. A retraction receipt
  also forgets each deleted record's active reservation, only on release.
- The gateway-for-own-writes role compares against the frozen application build.

Docs: a new ExchangeLedgerStorage article (C1-C5, the backend table, cost, holds);
the contract and HealthKit recipes now build a producer over the app's ledger, store
graph.json verbatim and release the receipt after the cursor commit; the HealthKit
catalog lists the exporter and its receipt, and three cross-module symbol links that
could not resolve (two pre-existing) are plain code voice. Both modules' DocC builds
report no symbol-link warnings.

Audit ledger findings addressed: section 2 findings 1 (unfrozen facts), 2 (instance
read in a second transaction), 3 (key-only release), 4 (keys not scoped to subject or
repository, via the fingerprint), 5 (stored ledger not validated), 6 (implicit
retention and the false releaseIgnoringErrors doc), 7 (storage contract), and the
recipe part of 11; section 3 findings 4 (two deletions of one record shared an
event), 5 (empty receipt opened a transaction), 6 (positional ECG symptom pairing),
7 (export(records:) untested, now through the internal seam) and the Receipt part of 8.

Deviations from the design: ledgerFingerprint frames every derived system, not only
the event system; a stored producer instance must state an RFC 4122 version, as
every event identifier requires; the deprecated converter keeps its positional
symptom contexts (HealthKitAssembly.SymptomRequests.positional) so its refusal order
is unchanged.

Tests: the sequencer and storage-contract tests move to GroveFHIRTests (G1, G5-G10,
G12-G15, G18 codec), with CountingStorage, FaultyStorage and a single-file
FileStorage; exporter tests G2, G3, G3b, G4, G8-G11, G17, G18, E1 and E2 in
GroveHealthKitFHIRTests. The existing exporter tests now read sequences from the
graphs, as new keys take sequences in sorted request order, and
refusalsDoNotEndTheExport asserts that a refusal holds its reservation.

Output unchanged: every golden is byte-identical, verified by GoldenGraphTests and
the new G17 digest table at output revision 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Frozen facts no longer rest on convention. HealthKitAssembly stored a whole
ExchangeEnvelope built from the producer's live application, host and studies, and
each graph swapped its reservation's facts in through with(_:); a call site that
forgot the swap would have stated live facts under a frozen event.

- ExchangeEnvelope is now its Scope (adapter, identity scope, subject, repository
  scope) plus the event's facts. HealthKitAssembly keeps only the Scope and builds
  ExchangeEnvelope(scope:facts:) per graph from the request's frozen facts; with(_:)
  is gone, and the exporter no longer hands producer facts to the assembly at all.
- ExchangeProducer prepares its facts once at init (encode, digest, decode back
  through the validating initializers). Facts that do not survive that round trip
  are a configuration fault, ExchangeProducer.ConfigurationError.unfreezableFacts,
  instead of a LedgerError.corruptEntry whose remedy (reset) could never fix them.
  Exporters reserve through ExchangeProducer.reserve(_:at:), which passes those
  prepared facts, so a call no longer re-encodes, hashes and decodes them.
  ExchangeEventSequencer.reserve(_:at:facts:) takes the PreparedFacts and is internal.
- PreparedFacts decoding no longer throws corruptEntry as control flow from its
  canonical helper; invalid stored values map to corruptEntry in one place.
- The ExchangeProducer init doc names the sequencer as the ledger of sequences and
  frozen facts, as its property doc already did.

Audit ledger: section 2 finding 1 (unfrozen facts), now enforced by construction.
G1 review items: design M2, minor 1, the protocolURL nit, minor 3(e).

Output unchanged: GroveHealthKitFHIR macOS suite including GoldenGraphTests and the
G17 digest table passes with every golden byte-identical; GroveFHIR suite passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cleanup from the G1 review of the exchange ledger; no behaviour changes.

- One RFC 4122 check: a stored producer instance is validated with
  ExchangeEventIdentifier.statesRFC4122Version, the check every event identifier
  already passes, instead of a second bit-level copy in LedgerEntryCoding.
- One digest-form check: ExchangeIdentity.isUnpaddedBase64URLDigest (43 characters of
  base64url) serves both the opaque-identity validator and the ledger's facts
  digests; LedgerEntryCoding.isDigest is gone.
- Dead state removed: ExportContext.callParts (never read; only the framed bytes are
  used), ExchangeEventKey's Codable conformance (nothing encodes keys since the ledger
  stores them as entry keys), CountingStorage.snapshot (unused). The reservation's
  sequence is computed from its handle instead of stored twice.
- Renames: ReserveTransaction is ReserveCall (it is one reserve call, not a
  Transaction conformer); EventEntry.facts is factsDigest (the stored JSON member
  keeps its name "facts", so the entry layout is unchanged).
- The keyed ECG symptom lookup can no longer miss: validation admits only the seven
  symptom types, all registered source types, and the exporter plans a request for
  every registered symptom; the positional shape keys every symptom. The unreachable
  symptomContextCountMismatch throw is a preconditionFailure naming that invariant.
- A bundled Patient that cannot be encoded fingerprints as the constant
  "unencodable" instead of a random UUID, so the fingerprint is deterministic; no
  JSON text equals it, and such a subject's events never carry a graph.
- G6 sleeps with Task.sleep(nanoseconds:), which the iOS 15 / macOS 12 floor has.
- CountingStorage's doc says it counts totals over every key, which is what G15
  bounds.

Not changed: LedgerCountingStorage (GroveHealthKitFHIRTests) and CountingStorage
(GroveFHIRTests) stay separate, as SwiftPM test targets cannot share sources and a
shared test-support target for one helper is disproportionate.

G1 review items: design minor 4 (RFC 4122 and digest), minor 5 (callParts, Codable,
reservation sequence, CountingStorage.snapshot), nits (ReserveTransaction,
EventEntry.facts, unreachable keyed miss, subject fallback, Task.sleep). HoldRegistry's
test-only count/isEmpty go with the hold-registry change that follows.

Output unchanged: GroveHealthKitFHIR macOS suite (goldens and the G17 digest table)
and GroveFHIR suite pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spec 1.4 has the ledger reader check that a stored reservation instant is one
ExchangeInstant can state; the reader did not. A stored instant of Int64.max reached
the conversion and surfaced as a dependency refusal (FHIRDateParserError.invalidYear)
instead of the typed ledger fault.

- EventEntry(decoding:) requires -62_135_596_800_000 <= instant <= 253_402_300_799_999
  (0001-01-01T00:00:00Z through 9999-12-31T23:59:59.999Z, now
  ExchangeInstant.statableMilliseconds) and throws LedgerError.corruptEntry otherwise;
  reset() recovers, as for every other corrupt entry.
- reserve refuses such an instant before it touches the ledger, with the existing
  ExchangeIdentityError.invalidInstant. Without this the ledger would write an entry
  its own reader rejects, and a redelivery would end the whole call with corruptEntry.
  Deviation, needed for consistency: an export or retraction at an unstatable instant
  (only reachable with a caller-supplied instant, never with .now) now ends the call
  with that error instead of refusing each record with a dependency failure.
- The export and retract docs list what ends a call: an unstatable instant, a
  LedgerError (reset() recovers), and errors of the storage or of receive.

Tests: G14 rows for an instant after year 9999, before year 1 and Int64.max; both
bounds read back as valid reservations; reserve at unstatable instants (year 10000,
before year 1, infinity) throws and opens no transaction; an export and a retraction
at an unstatable instant throw and touch no ledger.

Audit ledger: section 2 finding 5 (stored ledger not validated). G1 review items:
design minor 2 and correctness minor 5 (PROBE-F), part of design minor 3(c).

Output unchanged: GroveHealthKitFHIR macOS suite (goldens and the G17 digest table)
and GroveFHIR suite pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A narrow window let a release remove a reservation another call had just reused:
the last holder's finish ended its hold, then opened its removal transaction; a
reserve of the same key that committed in between reused the stored reservation and
took a hold, and the removal then deleted it under that holder. The holder's
redelivery became a new event: a duplicate, never a reuse, but avoidable.

- HoldRegistry also tracks the reserves in flight, per event key of one ledger.
  reserve registers its keys before its transaction and, after it, turns them into
  holds in one locked step (or just drops them when the transaction did not commit),
  so a reservation it may reuse is never seen unused in between.
- finish checks, inside its removing transaction, that no live call holds the exact
  handle and no reserve of its key is in flight on the same ledger before removing
  it. A reserve that registered before the check keeps the reservation; one that
  registers later starts its transaction after the removal, as a storage runs one
  process's transactions one at a time (every backend in the article's table does).
  The registry lock is taken inside the transaction only briefly and never held
  across I/O.
- Holds stay keyed by handle, which includes the producer instance and so is unique
  across ledgers. Keys in flight are scoped to the ledger: ExchangeEventSequencer
  identifies it by its storage object (so sequencers over one storage share it), or
  by itself for a storage that is not a class instance. Scoping by key alone would
  let any ledger in the process (another participant's, or a parallel test's) that
  reserves the same record keep this ledger's reservation.
- The test-only HoldRegistry.count and isEmpty are replaced by mayBeReused(_:in:),
  which finish needs; the tests that read them now assert per handle, including that
  a failed reserve leaves no key in flight.
- The storage article's holds section states the in-flight rule and its premise.

Tests (G9f, GroveFHIRTests): the review's PROBE-G as a test, where a reserve commits
between the last holder's end and its removal transaction; the in-flight case, where
the release runs while the reserve waits for its transaction; and a reserve of the
same key in flight on another storage, which keeps nothing. Verified by mutating and
running the suite: without the check in finish the first two fail, registering keys
only after the commit fails the in-flight one, and scoping keys without their ledger
fails the other-storage one.

G1 review items: correctness minor 7 (PROBE-G), design minor 5 (HoldRegistry
count/isEmpty). Audit ledger: section 2 finding 3 (release ownership).

Output unchanged: GroveHealthKitFHIR macOS suite (goldens and the G17 digest table)
and GroveFHIR suite pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ExchangeLedgerStorage: the keychain's C2 cell again says that the commit surviving
  power loss is Apple's guarantee and unverified here, as the design states it
  ([unverified] in spec 1.3); the article had dropped the caveat.
- ExchangeLedgerStorage and the Receipt doc no longer claim a release opens no
  transaction "when nothing was reserved": a retraction's receipt forgets each
  deleted record's active reservation, so its release runs one transaction whenever
  it names a deletion, even when every deletion was of a type with nothing to retract
  (PROBE-C; the behaviour is per spec 1.5.3 and is kept). An export's receipt runs
  none when it reserved nothing or another call still holds its events.
- ExchangeLedgerStorage's "a release only ever removes the exact reservation its call
  made" now names its one exception, a retraction's forgetting.
- The GroveFHIRContract article again tells adapters that convert under an
  ExchangeEventContext the caller builds (GroveSensorKitFHIR today) to persist the
  producer instance and durably advance the next sequence before emitting, in the
  event-identifier section and the what-to-persist table; the ledger recipe had
  dropped that guidance.

The export and retract docs naming LedgerError and the reset() recovery landed with
the instant validation, which rewrote the same sentences; the ExchangeProducer init
doc landed with the prepared facts.

Audit ledger: section 2 finding 11 (contract article), section 3 findings 5 and 8
(receipt cost and docs). G1 review items: design minor 3(a), 3(b), 3(d); correctness
minor 6.

Output unchanged: documentation and doc comments only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The G1 review re-applied 42 mutations to ffec7fb; 11 survived the suite. Each now
fails it.

- G3 computes every context's request fingerprint directly
  (exporter.context.request(for:).fingerprint) and requires the base context and its
  30 perturbations to be pairwise distinct, naming any colliding pair. It shared one
  ledger without releases before, so each perturbation was only compared with the
  previous one and a dropped part that followed another perturbation passed. New
  perturbations: the identity scope's key id (same systems, so only
  ledgerFingerprint can tell it) and the bundled Patient's content (same pseudonym).
  A second test ties the direct fingerprints to the export path: the stored
  reservation carries the base context's fingerprint, an equal context reuses the
  event and a perturbed one does not.
- G3b compares, for the defaults and every option perturbation, each stored
  property's own value parts (through Mirror) with the parts fingerprintParts pairs
  with its name, so a value paired with the wrong name fails, not only a missing name.
- G2 for gatewayForOwnWrites: a self-written sample (revision 100) under build 100,
  redelivered before release by a producer rebuilt with build 110, is the same event
  and byte-identical; the comparison uses the frozen build. After release the new
  event compares with build 110 and states no gateway.
- A three-deletion retraction takes one reserve transaction and its release one more.
- A retraction with only nothing-to-retract deletions reserves nothing, and its
  release runs one transaction that forgets the active key (PROBE-C, documenting the
  behaviour the corrected docs describe).
- A released receipt dropped while another call holds the same event leaves the
  event stored: deinit after release() ends no hold a second time.
- Record paths through export(records:): .electrocardiogram (refused by the record
  path's voltage validation, as the fixture ECG reports no voltage count; its and its
  symptom's reservations are held until release), .heartbeatSeries and an authorized
  .workoutRoute (byte-equal to the assembly's graph under the same event), and a route
  the policy omits, which delivers nothing, keeps the export going and holds its
  event until release.
- E1 on every OS: a duplicated symptom is refused as duplicateSymptomSource, so the
  keyed ECG path is covered where no unregistered category type exists (before OS 27).

Mutation acceptance (each re-applied to the final tree of this series, full
GroveHealthKitFHIRTests target on macOS, then reverted; M5b re-expressed for
producer.reserve(_:at:), the others with the review's exact texts):

| Mutation | Change | Result | Failing tests |
| --- | --- | --- | --- |
| M2b | gatewayApplication build dropped from the fingerprint | FAIL | G3 pairwise ("gateway build" = "role gatewayApplication") |
| M2c | native-identifier type display dropped | FAIL | G3 pairwise ("type display" = "type") |
| M2g | repository scope dropped | FAIL | G3 pairwise ("repository scope" = base) |
| M2m | legacyBundleID .healthKitUUID fingerprints as none | FAIL | G3 pairwise ("legacyBundleID" = base) |
| M2o | udi paired with a constant .omit | FAIL | G3 pairwise ("udi" = base), G3b value pairing |
| M2j | bundled Patient JSON dropped | FAIL | G3 pairwise ("bundled Patient content" = "bundled subject") |
| M2k | key id dropped from ledgerFingerprint | FAIL | G3 pairwise ("key id" = base) |
| M1c | gatewayForOwnWrites compares with the live build | FAIL | G2 gatewayForOwnWrites |
| M5b | one reserve transaction per retraction request | FAIL | three-deletion retraction (3 transactions) |
| M4b | Receipt.deinit ignores isFinished | FAIL | released receipt dropped while another holds |
| ME1a | keyed symptom requests count-checked | FAIL | E1 duplicate symptom, E1 unregistered symptom |

G1 review items: design M1; correctness MAJOR 1 and 2, minors 3, 4, 6 (test), 8 and
the E1-on-every-OS item.

Output unchanged: tests only; GroveHealthKitFHIR macOS suite passes (266 tests, 2
skipped by availability) with every golden byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OpaqueIdentityScope.ledgerFingerprint must separate scopes by key id, epoch,
key and systems, but only the HealthKit exporter's G3 pairwise test checked
it. Dropping the key id from the fingerprint (review mutation M2k) passed the
GroveFHIR suite, so a build or platform that runs only the contract's tests
would miss the regression.

- GroveFHIRTests gains LedgerFingerprintTests. Scopes that differ only in the
  key id, the epoch, the key, the ten opaque systems, the event system or the
  entry-node system fingerprint pairwise apart, and the test names any
  colliding pair. Equal scopes fingerprint equal, as an unpadded base64url
  SHA-256 digest that does not contain the key.
- The HealthKit suite's ledgerFingerprintIsKeyed is removed, because the new
  tests cover everything it checked. G3 keeps its key-id perturbation.

Verified by mutation, each run against the new suite (GroveFHIRTests):
- M2k, key id dropped from the parts: fails ("key id shares its ledger
  fingerprint with base").
- Epoch dropped: fails ("epoch shares ...").
- systems.all replaced by systems.opaque.all: fails ("event system shares ...").

G1 verification finding: gates-mutations nit (M2k caught only by the
HealthKit suite).

Output unchanged: test-only change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The G1 verification said ExchangeProducer.ConfigurationError.unfreezableFacts
was unreachable and asked for it to be removed in favour of a
preconditionFailure. It is reachable. URLComponents().url, and also
NSURL(string: ""), is a URL whose text is empty (checked on this macOS 27
toolchain). StudyEnrollment accepts it as its protocol canonical. The ledger
then stores "" as protocolURL, and Canonical parsing cannot read that back, so
PreparedFacts refuses the facts. A preconditionFailure there would crash the
app at producer construction over a configuration fault. The case therefore
stays, and this commit covers it:

- The case's doc names that example.
- ExchangeProducerTests gains unfreezableFactsAreRefused, which builds the
  producer with that enrollment and expects .unfreezableFacts; a well-formed
  enrollment still builds.
- The diagnostic test now includes the case.

Verified by mutation: with PreparedFacts.init using the caller's facts
instead of decoding its own bytes, the new test fails ("an error was expected
but none was thrown"). ExchangeProducerTests and OpaqueIdentityScopeRootTests
pass on the fixed tree.

G1 verification finding: deviations-concurrency minor (unfreezableFacts),
rejected with the reason above.

Output unchanged: no source behaviour changes, only a doc comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The in-flight protection from b53930c scoped a reserve's keys by the storage
object, or by the sequencer for a value-type storage. Sequencers over two
objects or values in front of one backing store therefore did not see each
other's reserves. A release could remove a reservation that another call had
just reused and committed but not yet held, and that call's redelivery became
a new event. MHC as specified (spec 5.3 and 5.5) creates exactly this setup,
because FHIRExchangeLedger.sequencer(accountDataGeneration:) runs per batch.
The verification reproduced the race with two wrapper objects, two value
wrappers, and two FileStorage objects on one directory.

How the protection works now:
- A reserve notes the exact handles its transaction returns. It does this
  from inside the transaction, before the commit, and turns them into holds in
  one locked step after it, or drops them when the transaction did not commit.
  The notes live in a per-call HoldRegistry.Notes and are deduplicated under
  the registry lock, so an attempt the storage runs again notes nothing twice.
- finish removes a reservation only when, checked inside its own transaction,
  no live call holds the handle and no reserve has noted it. A storage runs one
  process's transactions one at a time. So a reserve whose transaction ran
  first has already noted the handle, and one that runs after the removal no
  longer finds the reservation.
- Handles include the producer instance, which every ledger mints for itself
  (C4 forbids cloning a ledger). They are therefore unique across ledgers and
  the same through every object, value or sequencer in front of one ledger.
  LedgerIdentity, the AnyClass branch, the ledger property and the per-ledger
  key map are removed. HoldRegistry.mayBeReused(_:in:) is renamed isHeld(_:).
- The verification's option (a), scoping keys alone process-wide, is not
  taken. It couples unrelated ledgers in one process: a parallel HealthKit test
  that reserves the same fixture record on the shared registry would keep
  another test's reservation. The test that a reserve of the same key on
  another ledger keeps nothing fails under key-only scoping.
- One behaviour changes at the boundary. A reserve that has not started its
  transaction when the release's transaction runs is now serialized after the
  release. It finds the reservation removed, takes a new event and holds it.
  Before, its keys counted as in flight from the start of the call, so it
  reused the released event. The old G9f in-flight test now checks the serial
  outcome. In both versions, a reservation a call holds is never removed.
- Bodies now note handles in process memory until the call returns.
  Storage.transaction and the article therefore promise no effect outside the
  transaction "that outlasts the call", which still lets a storage run an
  attempt again.

Tests (GroveFHIRTests, ExchangeEventHoldRaceTests):
- The verification's race, with the reuse gated after its commit and before
  its hold, through two storage objects, two storage values (parameterized),
  and two FileStorage objects on one directory. Each case checks that the
  reservation is kept and that the reusing call's redelivery is an exact retry.
- A reservation that another ledger is about to hold under the same key keeps
  nothing in this ledger.
- A release whose transaction runs before a waiting reserve's removes the
  reservation, and that reserve's redelivery retries its own new event.
- G7 checks that a failed attempt leaves none of the handles it would have
  returned noted.

Verified by mutation (GroveFHIRTests ledger suites):
- b17f10d mechanism restored: the objects, values and file cases fail, and so
  does the waiting-reserve case.
- Notes taken after the commit: the objects, values and file cases fail.
- finish checking holds only: the objects, values and file cases fail.
- Notes never ended: G7, G8, G9a, G9d and the G9f removal cases fail.
- Key-only scoping, option (a): the other-ledger case fails.

G1 verification finding: deviations-concurrency minor (sequencers over
separate storage wrappers). The MHC spec needs no change, because
FHIRExchangeLedger may build a sequencer and a storage object per batch.
Audit ledger: section 2 finding 3 (release ownership).

Output unchanged, verified by the GroveHealthKitFHIR macOS suite (goldens and
the G17 digest table) and the GroveFHIR suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The article said the in-flight rule relies on the storage running one
process's transactions one at a time, "as every backend above does". Its own
SQL row, however, allowed bare serializable isolation. Serializable engines
that let transactions overlap, such as snapshot isolation with validation or
optimistic concurrency, do not run them one at a time. On such an engine a
release can check for holders before a concurrent reuse commits, and then
remove the reservation anyway, because the reuse writes nothing the release
read. The verification reproduced this with an optimistic backend that meets
C1 to C5.

The fix is documentation, one of the two options the verification offered.
Making the reuse write instead would add a write to every reuse-only call
(spec 1.5.1 cost).
- SQL row, C3: a transaction that takes the write lock when it begins, such as
  SQLite's BEGIN IMMEDIATE. Serializable isolation whose transactions overlap
  also meets C3, with the cost the Holds and receipts section describes. MHC's
  GRDB DatabaseQueue with .immediate is the first form.
- Holds and receipts: the premise is now its own sentence and names how every
  backend meets it (its lock, or a write-locking transaction). A storage whose
  serializable transactions overlap still never reuses a sequence. A release
  there can remove a reservation that a concurrent call has just reused, and
  that call's redelivery becomes a duplicate.
- The Storage protocol doc says the same, so the API reference states it where
  an implementer reads.

Tests (GroveFHIRTests):
- OptimisticStorage, a test storage whose serializable transactions overlap:
  snapshot reads, buffered writes, backward validation, and a re-run on
  conflict.
- G6 (concurrent reserves, releases and resets, checked by the reuse oracle)
  now also runs over OptimisticStorage and finds no reuse. This supports
  "still never reuses a sequence".
- A new G9f case pins the documented outcome. The release is gated between its
  body and its commit, a reuse commits in between, and the holder's
  redelivery takes a new, higher sequence under the same instance.
The change itself is documentation, so no test can fail without it; the new
tests check the claims the documentation makes. G6 and the new case also pass
on b17f10d's mechanism, because the outcome does not depend on it.

G1 verification finding: deviations-concurrency minor (a serializable backend
whose transactions overlap). Deviation from spec 1.3: the SQL C3 cell is
reworded as above.

Output unchanged: documentation and tests only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes two nits from the G1 fix verification.

Test: a storage may discard an attempt and run a transaction's body again
(clause wording in ExchangeLedgerStorage.md). HoldRegistry.beginReserving
dedups the notes per reserve call, so an attempt that re-runs and returns
the same handle notes it once; nothing pinned that. The new test
ExchangeEventHoldTests.retriedReserveNotesOnce goes through a test-only
RetryingStorage that runs the next transaction's body once, discards that
attempt, then runs it again and commits. Both attempts reuse one
reservation; after releasing, the registry no longer holds the handle and
the reservation is gone. Ported from the G1 fix-verification probe
(g1fv-probe/G1FixVerifyNotesProbeTests.swift, rerunAttemptNotesOnce).

Mutation proof: beginReserving counting every attempt
(`for handle in handles { notes.handles.insert(handle);
reserving[handle, default: 0] += 1 }`) fails exactly this test, 7/8 of
ExchangeEventHoldTests passing, with
"Fixtures.isHeld(first[request]?.handle, by: sequencer) -> false: nothing
stays noted after the retried call" and "the release removes the
reservation". Reverted; the suite passes 8/8.

Docs: "its bodies have no effect outside the transaction that outlasts the
call" (ExchangeEventSequencer.Storage.transaction and
ExchangeLedgerStorage.md) read as if the transaction outlasted the call. It
now says the bodies have no effect outside the transaction "other than
process-memory notes that end when the call returns". The Holds section now
says "every backend above in its primary form", because the SQL row also
admits overlapping serializable transactions, which the next sentence
treats separately.

Output unchanged: no source behaviour changed (doc comments and a test
only); all 43 goldens and the G17 table pass unchanged.

Gates:
- GroveHealthKitFHIR macOS: Passed, 266 total, 264 passed, 2 skipped
  (same 2 skips as at 35f6894), 0 failed.
- GroveFHIR macOS: Passed, 103/103.
- GroveSensorKitFHIR macOS: Passed, 83/83.
- GroveQuestionnaire macOS: Passed, 260/260.
- GROVE_LOWERED_DEPLOYMENT_TARGETS=1 xcrun swift build --target
  GroveFHIRContract: Build complete.
- swiftlint lint --strict --quiet (GroveFHIRContract, GroveHealthKitFHIR,
  GroveHealthKitFHIRTests, GroveFHIRTests): no findings.
- reuse lint: compliant (2873/2873).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fix-round step G2 (final-spec (c) 3.1/3.2, (g) step 4, tests W1-W4 of (f),
fingerprint 2.4). HKSourceRevision does not say whether a source is an
application or a device, and the pinned HealthKit guide forbids classifying
it from the identifier's shape, the source name or productType
(ig-pin healthkit/input/pagecontent/mapping.md:142-143); only the caller's
classification states a writer, and the author's name, identifier and
version are copied from that sample's HKSourceRevision (:136-137, :144).

Closes (rework-inputs/audit/ledger.md):
- 3, confirmed finding 1 (major): the .automatic default classified sources
  by hasPrefix("com.apple.health."). Deleted; the default is .omit.
- 4, confirmed finding 2 (minor, both directions): the recording Device now
  exists only when RecordingDevicePolicy resolved one from HKDevice, so
  recordingDeviceOmitted fires exactly when an HKDevice was declined (never
  next to a resolved or source-derived Device, never lost for a declined
  one).
- 3, confirmed finding 8, HealthKitWriter docs: the .device doc that
  contradicted inference and the "is an application" / "This is the
  default" sentences are gone.

API:
- WriterPolicy = .omit (default) | .applications(Set<String>) |
  .classify(@sendable (HKSource) -> HealthKitWriter); it loses Hashable
  (it holds a closure). HealthKitWriter = .application | .omit.
- HealthKitFHIRExporter.Options.writer and HealthKitConversionOptions.writer
  default to .omit, and with them HealthKitAssembly's default request
  options and the retraction request options.
- Deleted: WriterPolicy.automatic/.application/.device,
  HealthKitWriter.device, ExchangeWriterDraft.recordingDevice and the
  assembler branch that made the recording Device the author,
  SourceFacts.recordingDevice(fromAppleSource:) and its call,
  HealthKitConverter.appleDeviceSourcePrefix. What RecordingDevicePolicy
  states from HKDevice is unchanged.
- Context fingerprint writer part: ["omit"] | ["applications", <count>,
  <ids in UTF-8 byte order>] | ["classify"], by an exhaustive switch. The
  count keeps the length-framed sequence unambiguous; byte order keeps it
  independent of insertion order and collation.
- HealthKitAssembly.outputRevision 1 -> 2. ExchangeGraphAssembler's stays
  1: the deleted draft case had no other producer, so the assembler emits
  the same bytes for every draft that still exists.
- Docs: WriterPolicy, HealthKitWriter, HealthKitConversionOptions.writer,
  TheConversionGraph.md, ConfiguringAConversion.md, and a short note in the
  GroveHealthKitFHIR.md exporter recipe on classifying sources.

Output diff (goldens regenerated through TEST_RUNNER_GROVE_GOLDEN_OUTPUT_DIR
outside the worktree, compared token-losslessly, then copied). Every change
is in the allowed class: the writer application Device and its writer host
Device are removed, the Provenance author agent is removed, nothing else in
any graph changes (all other entries, entry order, references and warnings
identical; no dangling reference). Changed because the fixture relied on
the default writer (foreign writer org.example.writer, now unclassified):
- bundled-patient-subject:
    $.entry[5] removed: Device urn:uuid:27bc90ae-3192-5159-990d-4764c5569192 (writer host, grove-host-device)
    $.entry[6] removed: Device urn:uuid:dafff25f-7b2a-50f1-8062-35eaaa6d3c23 (writer application, healthkit-application-device)
    $.entry[7->5].resource.entity[0].agent removed: [author -> urn:uuid:dafff25f-7b2a-50f1-8062-35eaaa6d3c23] (the removed writer application)
- electrocardiogram:
    $.entry[5] removed: Device urn:uuid:659ba41a-b773-5177-9923-8a596c45de30 (writer host, grove-host-device)
    $.entry[6] removed: Device urn:uuid:ac9e967e-1606-58e5-8a33-72e6f7e602af (writer application, healthkit-application-device)
    $.entry[7->5].resource.entity[0].agent removed: [author -> urn:uuid:ac9e967e-1606-58e5-8a33-72e6f7e602af] (the removed writer application)
- electrocardiogram-symptom-companion:
    $.entry[4] removed: Device urn:uuid:342152e5-0fa1-578f-ae92-0d3bc245ea35 (writer host, grove-host-device)
    $.entry[5] removed: Device urn:uuid:7cdf03d0-e248-5b92-901d-299e17845605 (writer application, healthkit-application-device)
    $.entry[6->4].resource.entity[0].agent removed: [author -> urn:uuid:7cdf03d0-e248-5b92-901d-299e17845605] (the removed writer application)
- electrocardiogram-with-symptom:
    $.entry[5] removed: Device urn:uuid:08844702-36cb-57ff-b249-2b1b843fb79f (writer host, grove-host-device)
    $.entry[6] removed: Device urn:uuid:965a804c-2f33-5fe0-ae1e-a4a328007269 (writer application, healthkit-application-device)
    $.entry[7->5].resource.entity[0].agent removed: [author -> urn:uuid:965a804c-2f33-5fe0-ae1e-a4a328007269] (the removed writer application)
- gateway-application-role:
    $.entry[5] removed: Device urn:uuid:48aeeb20-6643-5b41-b3a3-44093a548f15 (writer host, grove-host-device)
    $.entry[6] removed: Device urn:uuid:480efa67-eb12-5f15-92ba-fbc30cf24d40 (writer application, healthkit-application-device)
    $.entry[7->5].resource.entity[0].agent removed: [author -> urn:uuid:480efa67-eb12-5f15-92ba-fbc30cf24d40] (the removed writer application)
- gateway-role:
    $.entry[4] removed: Device urn:uuid:1f896505-6996-55e4-8231-8412a4c864a8 (writer host, grove-host-device)
    $.entry[5] removed: Device urn:uuid:1fb8e303-b58a-5107-ac2d-9ddced20bb00 (writer application, healthkit-application-device)
    $.entry[6->4].resource.entity[0].agent removed: [author -> urn:uuid:1fb8e303-b58a-5107-ac2d-9ddced20bb00] (the removed writer application)
- heartbeat-series:
    $.entry[4] removed: Device urn:uuid:35f19e6b-6e60-5b6c-b203-d4ac0dce18aa (writer host, grove-host-device)
    $.entry[5] removed: Device urn:uuid:f72582bb-3a7c-5255-808c-cdba39fd1a84 (writer application, healthkit-application-device)
    $.entry[6->4].resource.entity[0].agent removed: [author -> urn:uuid:f72582bb-3a7c-5255-808c-cdba39fd1a84] (the removed writer application)
- native-identifier-disclosure:
    $.entry[4] removed: Device urn:uuid:51571f65-d367-5848-9557-f958f42357bc (writer host, grove-host-device)
    $.entry[5] removed: Device urn:uuid:647ca0a1-1154-58d5-bb7c-a880387bbbcb (writer application, healthkit-application-device)
    $.entry[6->4].resource.entity[0].agent removed: [author -> urn:uuid:647ca0a1-1154-58d5-bb7c-a880387bbbcb] (the removed writer application)
- two-study-enrollments:
    $.entry[10] removed: Device urn:uuid:2a8208fd-d3b8-5a36-8e6c-fe64275ae3da (writer host, grove-host-device)
    $.entry[11] removed: Device urn:uuid:e34dda89-a30e-5394-b8ee-b0595e1dd7c7 (writer application, healthkit-application-device)
    $.entry[12->10].resource.entity[0].agent removed: [author -> urn:uuid:e34dda89-a30e-5394-b8ee-b0595e1dd7c7] (the removed writer application)
- udi-disclosure:
    $.entry[4] removed: Device urn:uuid:590dd017-f9f1-5627-abdc-b1f47959e10e (writer host, grove-host-device)
    $.entry[5] removed: Device urn:uuid:4f6c6282-7153-5005-8765-7a6ad1ac2428 (writer application, healthkit-application-device)
    $.entry[6->4].resource.entity[0].agent removed: [author -> urn:uuid:4f6c6282-7153-5005-8765-7a6ad1ac2428] (the removed writer application)
- workout-route:
    $.entry[4] removed: Device urn:uuid:18fba38a-2ee6-5140-80f2-d84f938266b4 (writer host, grove-host-device)
    $.entry[5] removed: Device urn:uuid:d21d9031-3216-5431-b57f-1b2afa147c46 (writer application, healthkit-application-device)
    $.entry[6->4].resource.entity[0].agent removed: [author -> urn:uuid:d21d9031-3216-5431-b57f-1b2afa147c46] (the removed writer application)
- writer-device-classification.json -> writer-omitted.json:
    $.entry[4].resource.entity[0].agent removed: [author -> urn:uuid:4d25df34-97c0-5441-a4e4-74e7de79ed10] (the recording Device, which stays)
- writer-device-classification-without-recording-device.json ->
  writer-omitted-without-recording-device.json: renamed only, byte-identical
  (the .device classification it pinned no longer exists; under .omit it
  states exactly what it stated before).
- outlines.json: per changed case above, the two removed Device entries
  leave its outline (same fullUrls and identifiers as listed); the two
  writer-device-classification keys become writer-omitted and
  writer-omitted-without-recording-device with identical outlines; all
  warnings unchanged.
Unchanged (byte-identical): the 14 observation shapes, clinical-document,
the 5 retractions, repository-ids-on-every-node and the writer cases
writer-foreign-application, writer-self-build-equals-revision,
writer-self-older-build, writer-self-token-identical,
writer-host-equals-converter-host, writer-blank-name-with-sync-identity,
writer-without-version, sync-identity. These cases pin how a classified
writer travels (repository-ids-on-every-node names the writer nodes), so
their fixtures now pass .application explicitly; no other fixture changed.
G17: the 13 changed files carry healthKit: 2 with their new digests; the
byte-identical rename keeps healthKit: 1 under its new name.

Tests:
- New HealthKitFHIRExporterWriterTests: W1 exporter default (an Apple
  per-device source com.apple.health.<uuid>, "Lukas's Apple Watch", without
  HKDevice / with a declined HKDevice / with a resolved one: no writer, no
  author, no Device naming the source, the HKDevice's recording Device only
  when resolved, the warning only when declined) and W1 through
  HealthKitConversionOptions() and .default; W2 .applications (listed
  source states name, bundle id and version from HKSourceRevision and is
  the author, byte-equal to the entry point under .application; unlisted
  and Apple sources state nothing); W3 .classify (closure asked per source,
  .application states the revision's values, .omit nothing); W4 scans
  Sources/GroveHealthKitFHIR for the literal "com.apple.health. (and
  `grep -rn '"com.apple.health.' Sources/GroveHealthKitFHIR` finds nothing).
- Fingerprint: G3 perturbations are now writer applications, a second
  member, an empty set and classify; a writer-policy change gives a
  reserved record sequences 1, 2, 3 across omit -> applications -> omit;
  equal application sets built in different insertion orders (iteration
  orders checked to differ) fingerprint equally, a member less does not.
- Deleted automaticWriterClassifiesAppleDeviceSources (W1-W4 replace it).
  deviceSourceReusesRecordingDevice becomes "an unclassified source keeps
  the recording Device its HKDevice names, and the Provenance names no
  author". Test-helper writer defaults (HealthKitConversionContext test
  init, HealthKitDeviceIdentityTests) follow production to .omit; the tests
  that need a writer (invalidWriterBundleIdentifierIsRefused,
  converterReadsTheStoredFacts) pass .application.

Mutation proof (each applied alone, full GroveHealthKitFHIRTests run,
reverted; the pristine files compared equal afterwards):
- (a1) Options.writer default .classify { _ in .application }: fails W1
  exporter default, G3 (classify equals base), graphsEqualReference,
  heartbeatSeriesRecord, authorizedWorkoutRouteRecord (5 failed).
- (a2) HealthKitConversionOptions writer default .application: fails W1
  conversion options, healthKitOptionDefaults, matchesCheckedInGolden,
  documentGraphsOmitTheGatewayApplication, graphsEqualReference, the
  heartbeat and route record tests (7 failed).
- (b) .applications ignoring membership: fails W2 (org.example.other
  stated and named as author).
- (c) .classify result ignored (closure called, .application returned):
  fails W3 (org.example.other stated and named as author).
- (d) recording Device derived from a com.apple.health. source again: fails
  W1 (both) and W4 (3 failed).
- (e) recordingDeviceOmitted appended next to a resolved Device: fails W1
  (both), "A conversion reports exactly what the graph lost",
  matchesCheckedInGolden, graphsEqualReference (5 failed).
- (f) applications fingerprint parts left unsorted: fails the
  insertion-order fingerprint test.

Gates:
- GroveHealthKitFHIR macOS: Passed, 272 total, 270 passed, 2 skipped (the
  same 2 as before), 0 failed.
- GroveFHIR macOS: Passed, 103/103.
- GroveSensorKitFHIR macOS: Passed, 83/83.
- GroveQuestionnaire macOS: Passed, 260/260.
- GROVE_LOWERED_DEPLOYMENT_TARGETS=1 xcrun swift build --target
  GroveFHIRContract: Build complete.
- swiftlint lint --strict --quiet (GroveFHIRContract, GroveHealthKitFHIR,
  GroveHealthKitFHIRTests, GroveFHIRTests): no findings.
- reuse lint: compliant (2874/2874).
- Scripts/validate-fhir-conformance.sh healthkit: "OK - all healthkit
  resources conform to their declared R4 profiles" (28 resources), contract
  generator and semantic-vector checks clean. Guides: a scratchpad copy of
  the read-only ig-pin extract with mobile, sensor and healthkit built from
  it (0 unsuppressed errors or warnings); the extract has no git history,
  so the copy was git-initialized and GROVE_FHIR_REF pointed at that commit.
- xcodebuild docbuild GroveHealthKitFHIR: succeeded; no new warning in
  GroveHealthKitFHIR or GroveFHIRContract (the unused public HealthKit
  import warning in the Options file is gone, as WriterPolicy now exposes
  HKSource).

Size (A): code 11,143 (+1,438), public 1,006 (+133)
(vs baseline Grove 9b086e6; vs parent 77e8b20: code -29, public -4.
Level B: code 21,205 (+1,438), public 1,909 (+133).)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
G2 review fix round (final-spec 2.4, the context fingerprint; G3 of (f)).
The fingerprint tests could not tell two .applications sets of the same
size apart: G3 paired sets of sizes 1, 2 and 0, and the membership test
compared 24 members with 23. A fingerprint that kept only the member count
passed the whole suite (mutation M10, re-verified at 55b9fc5 together with
M24: 272 total, 270 passed, 2 skipped, 0 failed), and would reuse a reserved
event for different bytes when a deployment swaps one listed application for
another.

Tests (HealthKitFHIRExporterFingerprintTests):
- G3 gains "writer applications other" ({org.example.other}), the size of
  "writer applications" ({org.example.writer}) with another member.
- applicationSetsFingerprintByMembership also swaps a member for another
  (app0 -> app24) and expects a new fingerprint.
- New known-answer test: a fixed context (explicit output revisions 1/2, the
  test identity scope and subject, writer .applications({org.example.caff,
  org.example.cafe + U+0301})) has writer parts [applications, 2,
  org.example.cafe + U+0301, org.example.caff], compared byte for byte, and
  the fingerprint Y9AmAuXc9kZTd8ZjijBhBS7QD_7A8w4YKQLk7RXFRig. A reservation
  keeps the fingerprint it was made under across launches and app updates,
  so a build that derives the same context's fingerprint differently
  re-sequences every pending reservation on redelivery; the known answer
  makes such a change deliberate. The decomposed member separates UTF-8 byte
  order from Swift's String order.

Doc (WriterPolicy fingerprintParts): "equal sets fingerprint equally
whatever their insertion order or the platform's collation" overstated the
guarantee. Set equality is canonical equivalence while the fingerprint
frames raw UTF-8 (probe: Set(["org.caf\u{e9}"]) == Set(["org.cafe\u{301}"])
is true, their sorted parts compare == as Strings but differ as UTF-8). It
now says sets of the same strings, byte for byte, fingerprint equally, and
that canonically equal spellings fingerprint apart (a new sequence, never a
reuse).

Mutation proof (each applied alone to this tree, full GroveHealthKitFHIRTests
on macOS, then restored and compared byte-equal; unmutated: 274 total, 272
passed, 2 skipped):
- M09 (count dropped: ["applications"] + sorted): 1 failed, the known-answer
  test (parts and fingerprint).
- M10 (members dropped: ["applications", count]): 3 failed, the known-answer
  test, G3 ("writer applications other shares its fingerprint with writer
  applications") and the membership test ("another member in its place").
- M14 (bundleIdentifiers.sorted(), Swift String order): 1 failed, the
  known-answer test (parts in the other order, another fingerprint).

Output unchanged (no source change beyond a doc comment; goldens untouched).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s Exceptions

G2 review fix round (final-spec (c) 3.1; audit ledger 3, confirmed finding 8,
the HealthKitWriter docs; IG ig-pin healthkit/input/pagecontent/mapping.md:44-47
for the writer-record identity).

Writer record. G2 moved the sync-identity golden to an explicit
.applicationWriter and every converter test of the sync pair uses an
unattributed sample (empty bundle identifier), so nothing pinned that an
attributed sample from a source nobody classified still carries its
writer-record identity and version. A SourceFacts that derived the writer
record only under .application (mutation M24) passed the whole suite
(re-verified at 55b9fc5 together with M10: 272 total, 270 passed, 2
skipped, 0 failed). New test in HealthKitFHIRExporterWriterTests: a
foreign-writer heart rate with the sync pair (sync-abc, version 3) exported
under Options() and under .omit, .applications listing it, listing another,
listing none, .classify { .omit } and .classify { .application } carries
exactly one writer-record identifier, equal to the identity scope's
writerRecord(org.example.writer under the Apple bundle-identifier system,
sync-abc), and the version extension "3". The WriterPolicy doc now says the
sync pair travels as the writer-record identity under every policy.

Docs (WriterPolicy.applications, HealthKitWriter.application): a classified
source with a blank name or bundle identifier states no writer, and one whose
bundle identifier is not a valid Apple bundle identifier is refused with
HealthKitConversionError.sourceApplicationInvalid (SourceFacts.writer; the
host can never fail there). The goldens writer-blank-name-with-sync-identity
and GoldenGraphTests.invalidWriterBundleIdentifierIsRefused pin both.

ProducerSurfaceTests.writerNodes ended with a conversion that once pinned
.device and, after G2, repeated the test init's default .omit on an
unattributed sample, so its two expectations pinned nothing. It now pins the
other half of the test's claim: an attributed sample whose source the caller
classifies as an application carries both writer nodes, and repository ids
name each.

Mutation proof (applied alone, full GroveHealthKitFHIRTests on macOS, then
restored and compared byte-equal; unmutated: 274 total, 272 passed, 2
skipped): M24 fails the new writer-record test under the default, omit,
applications listing another, no applications and classified as omitted (no
writer-record identifier, no version); the two policies that classify the
source as an application still pass, as they should.

Output unchanged (goldens untouched; source changes are doc comments only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
G2 review fix round (final-spec (c) 3.2, which deleted the recording-Device
author branch). With that branch gone, ExchangeGraphAssembler.Devices.authorURL
was always writer?.applicationURL: one producer (resolveDevices) and one
reader (the Provenance call in assemble). The stored field is deleted; the
Provenance call reads devices.writer?.applicationURL and keeps the comment
that the writer application is the author and that without one the
Provenance names none. The Devices doc no longer claims to say who the
author is.

Output unchanged, verified by the GroveHealthKitFHIR goldens (byte-equal,
G17 rows untouched), the GroveSensorKitFHIR and GroveQuestionnaire suites
and the healthkit conformance run below. ExchangeGraphAssembler.outputRevision
stays 1.

Gates for this round (at this tree; the two earlier commits of the round
changed tests and doc comments only):
- GroveHealthKitFHIR macOS: Passed, 274 total, 272 passed, 2 skipped (the
  same 2 as before), 0 failed.
- GroveSensorKitFHIR macOS: Passed, 83/83.
- GroveQuestionnaire macOS: Passed, 260/260.
- GROVE_LOWERED_DEPLOYMENT_TARGETS=1 xcrun swift build --target
  GroveFHIRContract: Build complete.
- swiftlint lint --strict --quiet (GroveFHIRContract, GroveHealthKitFHIR,
  GroveHealthKitFHIRTests): no findings.
- reuse lint: compliant (2874/2874).
- Scripts/validate-fhir-conformance.sh healthkit (guides: the scratchpad copy
  of the read-only ig-pin extract built for G2): 28 resources, "OK - all
  healthkit resources conform to their declared R4 profiles"; contract
  generator and semantic-vector checks clean.
- xcodebuild docbuild GroveHealthKitFHIR (macOS): succeeded; no unresolved
  symbol link in GroveHealthKitFHIR or GroveFHIRContract (the new
  HealthKitConversionError/sourceApplicationInvalid links resolve).

Size (A): code 11,142 (+1,437), public 1,006 (+133)
(vs baseline Grove 9b086e6; vs 55b9fc5: code -1, public 0.
Level B: code 21,204 (+1,437), public 1,909 (+133).)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fix-round step G3, audit ledger section 1 confirmed finding 7 (the
comparator treats canonically equivalent strings as equal, every golden is
ASCII) and the LosslessJSONValue BOM item of the ledger decision list.

LosslessJSONValue decides ExchangeGraph.isSemanticallyEqual (the exact-retry
predicate) and every golden comparison. Its synthesized equality compared
strings and member names with Swift String ==, which is canonical
equivalence, so "Sant\u{E9}" equalled "Sante\u{301}"; the guide's reference
comparator (ig-pin Tests/test_receiver_lifecycle.py:50-60, json.loads with
string lexemes, then Python ==) compares code points. And the string parser
decoded each UTF-8 run with String(bytes:encoding:), which drops a leading
U+FEFF, so "\u{FEFF}x" parsed as "x" (also right after an escape).

- Equality is written out: strings and number lexemes compare by
  unicodeScalars; an object compares each member's value and the stored
  member name scalar by scalar (a dictionary lookup finds canonically
  equivalent names alike). Parsing is unchanged otherwise; two canonically
  equivalent member names in one object stay a duplicate, as
  StrictJSONScanner refuses them.
- UTF-8 runs are decoded with the UTF8 codec, which keeps every scalar.
- Docs: LosslessJSONValue and isSemanticallyEqual state the scalar rule.
- Test harness: TokenDiff reports a member name that differs only in its
  scalars, and prints non-ASCII scalars as \u{...} so a failure is legible.

Tests:
- GoldenGraphTests.tokensCompareUnicodeScalars: precomposed vs decomposed
  values and member names differ (with the reported path); a leading U+FEFF,
  raw or after an escape, is kept; "" escaped equals the raw scalar.
- New golden writer-non-ascii-name (writers group, sequence 30): the foreign
  application writer named "Sant\u{E9} Journal" under .applicationWriter, so
  a non-ASCII string is pinned in bytes. G17 row added at the current
  revisions (assembler 1, healthKit 2), no bump: no existing output changed.

Mutation proof (each applied alone, full GroveHealthKitFHIRTests on macOS,
then reverted; unmutated: 275 total, 273 passed, 2 skipped):
- synthesized equality restored: fails tokensCompareUnicodeScalars
  ("precomposed != decomposed").
- runs drop a leading U+FEFF again: fails tokensCompareUnicodeScalars.
- SourceFacts states the writer name decomposed
  (decomposedStringWithCanonicalMapping): fails matchesCheckedInGolden for
  writer-non-ascii-name at $.entry[5].resource.deviceName[0].name (golden
  "Sant\u{e9} Journal", actual "Sante\u{301} Journal"). Applied together
  with the synthesized equality, that golden passes again: only the scalar
  comparison catches the drift.

Output diff: none. All 42 existing goldens regenerate byte-identical
(TEST_RUNNER_GROVE_GOLDEN_OUTPUT_DIR outside the worktree); outlines.json
only gains the writer-non-ascii-name entry.

Gates: GroveHealthKitFHIR macOS 275 total, 273 passed, 2 skipped;
GroveSensorKitFHIR 83/83; GroveQuestionnaire 260/260;
GROVE_LOWERED_DEPLOYMENT_TARGETS=1 xcrun swift build --target
GroveFHIRContract: Build complete; swiftlint --strict: no findings; reuse
lint: compliant.

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

Fix-round step G3, audit ledger section 1 confirmed findings 5 (companion
graphs, conversion identifiers and the case list are unpinned), 8 (setting
GROVE_GOLDEN_OUTPUT_DIR skips the comparison and still reports success) and
the fixture half of 10 (the workout golden is built from the benchmark's
SampleFactory).

- Regeneration fails visibly: with GROVE_GOLDEN_OUTPUT_DIR set,
  everyCaseIsDeterministicAndPinnable writes every case as before and then
  records "Regenerated N goldens into <dir> and compared none", so a run
  that compared nothing can no longer end in TEST SUCCEEDED.
- Companions: GoldenOutput(primaryOf:companions:) requires the exact
  companion count of the set it takes the primary from (0 everywhere, 1 for
  electrocardiogram-with-symptom, whose companion has its own case). It
  replaces the five direct `.primary` call sites (GoldenFixtures.convert and
  the electrocardiogram, electrocardiogram-with-symptom, heartbeat-series and
  workout-route cases), which dropped any companion silently.
- Identities and source: GoldenOutput keeps the conversion's
  ExchangeGraphIdentifiers and HealthKitSourceRecord, and matchesCheckedInGolden
  reads them against the graph's own tokens (reportMismatches): every
  reported output and Device is the entry its fullUrl names and carries that
  identifier, the Provenance is the one entry at its entry-node fullUrl, the
  source-record and artifact identities sit on the primary output, every
  Observation, DocumentReference and Device entry is reported (a distinct
  gateway application aside), and the source re-mints the graph's
  source-record identity through the test identity scope. The graphs are
  pinned by the goldens, so this pins what the API reports beside them.
- Case list: already pinned by the G17 table (GoldenOutputRevisionTests,
  ffec7fb), whose literal rows must equal the checked-in goldens.
- The workout fixture is GoldenFixtures.workout(withEvents:), the same run
  written out in the fixtures, no longer read from the benchmark factory;
  workout-session regenerates byte-identical. GoldenCaseError.workoutNotBuilt
  is gone with the lookup it guarded.

Mutation proof (each applied alone, full GroveHealthKitFHIRTests on macOS,
then reverted; unmutated: 275 total, 273 passed, 2 skipped):
- an ECG without symptoms reports itself as its own companion: fails
  matchesCheckedInGolden, everyCaseIsDeterministicAndPinnable and two
  ExchangeGraphBytesTests with unexpectedCompanions(1). The same mutation
  against the previous harness (tests of e2e5397) passes all 275.
- the assembler reports no childOutputs: fails matchesCheckedInGolden
  (electrocardiogram: ["outputs"]).
- HealthKitAssembly reports every source as heart rate: fails
  matchesCheckedInGolden (step-count-period: ["source"]).
- a regeneration run (TEST_RUNNER_GROVE_GOLDEN_OUTPUT_DIR outside the
  worktree) fails with the regeneration issue; with the Issue.record turned
  into a print it passes, which is the finding.

Output diff: none. A regeneration writes all 43 goldens and outlines.json
byte-identical to the checked-in files.

Gates: GroveHealthKitFHIR macOS 275 total, 273 passed, 2 skipped;
swiftlint --strict: no findings; reuse lint: compliant (tests only).

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

Fix-round step G3, audit ledger section 1 confirmed findings 2 (no golden runs
through HealthKitFHIRExporter or MHC's options), 3 (HKClinicalRecord glue
unpinned), 4 (envelope options combined only with heart rate), the exporter
half of 5 (the exporter delivers every graph of a set) and survivor M5 of
finding 1 (effective time truncated to whole seconds); G2 leftover: no golden
had an Apple per-device (com.apple.health.<uuid>) source.

Every golden so far came from the deprecated converter, so the exporter's own
layer (frozen facts and studies in the envelope, the options, the legacy
Bundle.id, the gateway role for own writes, the ECG companion delivery, the
retraction path, the clinical-record glue) was pinned nowhere in bytes.

New golden group "exporter" (sequences 100-119, GoldenGraphCases+Exporter
.swift): each case exports through a fresh in-memory ledger whose producer
entry hands out the case's sequence next under the fixed producer instance of
every golden, at the golden conversion instant, so the events are as
reproducible as the converter's. GoldenOutput gains init(_ export:), which
records the export's source and its diagnostics as code@location.
- exporter-default-apple-watch-heart-rate: Options() on a heart rate whose
  source is com.apple.health.<uuid> ("Lukas's Apple Watch") and whose HKDevice
  names its unit: no writer, no author, the recording Device only (W1 in
  bytes).
- The deployment lane uses options equal to MyHeartCounts'
  Options.myHeartCounts (HealthKitGroveConversion.swift:45-51 at MHC 79d70dbf)
  under G2's writer policy: nativeIdentifier .authorized under the
  deployment's own system, legacyBundleID .healthKitUUID, role
  .gatewayForOwnWrites, writer .applications([own bundle id]), every other
  option at its default; the producer states an application with a build and
  one study enrollment, as MHC's does:
  - exporter-deployment-own-heart-rate: the deployment's own write in the
    running build, starting at a sub-second instant: gateway extension,
    classified writer and writer host, disclosed UUID, Bundle.id, study links.
  - exporter-deployment-electrocardiogram and ...-symptom: one export call
    of an ECG with a correlated symptom through the evidence seam; the call
    must deliver exactly these two graphs, the ECG's average-heart-rate child
    and the symptom companion each carrying the study link.
  - exporter-deployment-blood-pressure, exporter-deployment-state-of-mind:
    study links on a correlation and on state of mind.
  - exporter-deployment-retraction: a heart-rate deletion with sub-second
    bounds through retract: Bundle.id, the period at millisecond precision.
- exporter-clinical-record-r4 / -dstu2: an HKClinicalRecord per admitted
  release through export(_:), built by the new
  StoredSampleFixtures.clinicalRecord (KVC on HKClinicalRecord and
  HKFHIRResource, read back or fail), pinning convertClinicalRecord's glue.
  watchOS has no clinical records; both are in GoldenCase.unavailableHere
  there.
GoldenCase.bloodPressure takes its ordinals (defaults unchanged).

G17 rows: the 9 new goldens at assembler 1, healthKit 2 (current), no bump;
outlines.json row updated (it only gains the 9 entries; all existing
outlines unchanged).

Mutation proof (each applied alone, full GroveHealthKitFHIRTests on macOS,
then reverted; unmutated: 275 total, 273 passed, 2 skipped):
- audit M5, effective start truncated to whole seconds: fails
  matchesCheckedInGolden for exporter-deployment-own-heart-rate at
  $.entry[0].resource.effectiveDateTime (golden ...:00.512-07:00). Survived
  the whole suite before this commit.
- the ECG average-heart-rate child without the studies link: fails
  exporter-deployment-electrocardiogram (outline).
- the exporter builds every graph with no studies: fails
  exporter-deployment-own-heart-rate (outline).
- legacy Bundle.id lowercased: fails exporter-deployment-own-heart-rate at
  $.id, plus policiesApply and retractionBoundsAndLedger.
- convertClinicalRecord ignores the resource's release (always R4): fails
  exporter-clinical-record-dstu2 at ...attachment.contentType.

Output diff: none; the 43 existing goldens and their outlines regenerate
byte-identical.

Gates: GroveHealthKitFHIR macOS 275 total, 273 passed, 2 skipped;
swiftlint --strict: no findings; reuse lint: compliant (tests only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fix-round step G3, audit ledger section 1 confirmed findings 1 (six realistic
regressions passed the suite), 3 (the public ECG evidence path has no test),
6 (unit-converted values are untested anywhere) and 9 (the ECG test seam
lets the evidence and the sample metadata disagree on the entry method).

The audit's six mutations (SP/audit-vgoldcov-mine, sites relocated at
28f9311) all still passed the whole suite at 28f9311 (274 total, 272
passed, 2 skipped each). After this commit and the exporter goldens of the
previous one, five fail; the sixth is equivalent on the wire:
- M1 notification category mapping (values[value] -> the largest key's
  code): equivalent. The only notification table with more than one value is
  walking steadiness, which every code it can pick leaves refused (contract
  admits only low/very-low; bug present in both trees, audit differential).
  Track 3's FX-walking-steadiness makes it observable and re-runs it.
- M2 atrial fibrillation coded inconclusiveOther: fails the new
  classificationsStateTheGuideCodes.
- M3 assessment score + 1: fails the new golden gad7-assessment
  ($.entry[0].resource.valueQuantity.value: golden 9, actual 10).
- M4 ECG symptomsStatus read as notSet: fails the new
  sourceEvidenceReadsTheSample.
- M5 effective time truncated to whole seconds: fails
  exporter-deployment-own-heart-rate (9b2e2d5).
- M6 percent rounded to one decimal: fails convertsFromAnotherUnit
  (oxygen saturation: "7" for "7.000000000000001").

Tests:
- HealthKitECGEvidenceValidatorTests.sourceEvidenceReadsTheSample: the
  public record path's ecgSourceEvidence on a stored HKElectrocardiogram
  (new StoredSampleFixtures.electrocardiogram: KVC on _privateClassification,
  _symptomsStatus and _averageHeartRate, read back or fail; HealthKit keeps
  the classification under a private numbering, 4 reads back as
  atrialFibrillation): type, bounds, zone, classification, symptoms status,
  average rate, algorithm version, and no count or frequency for unset
  voltages.
- classificationsStateTheGuideCodes: every HKElectrocardiogram.
  Classification against the guide's closed code system
  (ig-pin healthkit/input/fsh/terminology.fsh:87-94), an independent oracle.
- Golden gad7-assessment (observations group, sequence 15): an
  HKGAD7Assessment HealthKit scores 9; a supported type no golden covered.
- HealthKitUnitBindingTests.convertsFromAnotherUnit: degF body temperature,
  miles, a percent fraction and mmol/L glucose, each pinned to its exact wire
  lexeme and code (37.00000000000006 Cel, 1609.344 m, 7.000000000000001 %,
  99.08573400002975 mg/dL). Track 3's FX-F4-percent changes the percent row
  as an enumerated delta.
- userEnteredECGMarksEveryOutput: an ECG whose metadata says it was user
  entered states manual entry on the waveform and on its average heart rate.

Source (finding 9): the ECG's average-heart-rate child took its entry method
from HealthKitECGSourceEvidence.wasUserEntered while the primary took it
from SourceFacts (the sample's metadata); both read HKMetadataKeyWasUserEntered
in production, but the evidence seam could make them disagree. The field is
deleted and HealthKitAssembly.graph states the sample's own entry method on
every output it assembles (documents take no manual-entry link, so they are
unaffected). Output unchanged: all 53 goldens regenerate byte-identical,
and HealthKitAssembly.outputRevision stays 2.

Mutation proof for the new test of the source change: stating the entry
method on outputs[0] only fails userEnteredECGMarksEveryOutput ("8867-4
states no manual entry"). Unmutated: 279 total, 277 passed, 2 skipped.

Output diff: none. New golden gad7-assessment with its G17 row (assembler 1,
healthKit 2, no bump); outlines.json only gains its entry.

Gates: GroveHealthKitFHIR macOS 279 total, 277 passed, 2 skipped;
swiftlint --strict: no findings; reuse lint: compliant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…L7 Lane

Fix-round step G3, audit ledger section 5 confirmed findings 1 (the specs'
merge gates and regression tests were not added; F11 and F7 could be removed
with the suite green; the workout Observation never met the HL7 validator)
and 6 (gatewayForOwnWrites without a build is undocumented and untested),
section 1 finding 10 (the workout golden has a bare context); G2 leftover:
no conformance fixture carried a Provenance author.

At 1c2f2a7 the audit's removals still passed everything: weakening the F11
reverse check (`guard governs else { continue }`) and reverting the F7
retraction instants, the clamp and DocumentReference.date (FHIRModels'
DateTime(utc:)/Instant(utc:) again) each left 279/277 green.

Tests:
- F11 T1-T4 (new AdapterProvenanceGraphTests, ungated): a golden edited as a
  plain JSON object and re-validated through ExchangeGraph(validating:):
  HealthKit outputs under the mobile, SensorKit and providers Provenance
  report adapter-provenance-graph @ Bundle.entry; under the Health Connect
  Provenance data-origin-agent @ Provenance.entity[0].agent (the graph rule
  does not pre-empt it); a HealthKit Provenance also targeting a
  source-neutral output of its record reports @ Provenance.target, the same
  output claiming the adapter is accepted; a Patient claiming
  healthkit-observation reports mobile-exchange.unclassified @ Bundle. Every
  expectation was computed by running the pinned kit's
  exchange_bundle_diagnostics on the same edits (ig-pin, read-only,
  PYTHONDONTWRITEBYTECODE=1) and equals the F11 spec's.
- F11 T5 (gated ExchangeGraphCorpusTests): ExchangeGraph.adapterConversionClaims
  equals catalog/profile-claims.json adapterConversionProvenanceClaims in
  order, profile and target set (healthkit 119, health-connect 6, providers 4,
  sensorkit 13).
- F1: GoldenGraphTests.workoutRetractionIsExact (the catalog states
  workout|single, and the retraction's targets are exactly the outputs the
  addition emitted); HealthKitFHIRExporterTests.workoutExportAndRetraction
  (one Observation exported, the retraction targets exactly its session).
  New golden workout-session-context (observations group, sequence 16): the
  session under a study, a gateway application, manual entry and its
  recording watch, so every link a workout takes is pinned in bytes.
- F7 (ExchangeEnvelopeFixTests): retractionInstantsAreMilliseconds (period
  start .0004 -> 22:30:00Z, end .9996 carries into 23:30:01Z, recorded and
  Bundle.timestamp .2514 -> .251Z, an instant .2516 -> .252Z, a start of
  .distantPast clamped to 0001-01-01T00:00:00Z) and documentDateIsMilliseconds.
- F3 negatives and finding 6: sameBuildIsExact, HealthKitAssembly.isSameBuild
  over the exact build (true) and revision "41", nil, "   ", "42 ", a
  foreign bundle and a converter without a build whose version equals the
  revision (all false).
- F2: writerEqualToAnUnnamedGatewayIsItsOwnSnapshot: a heartbeat series under
  a gateway application whose token equals the classified writer's carries
  the writer as its own snapshot (name from HKSourceRevision, parent the
  writer host, repository id w-1) and as the Provenance author; on an
  Observation, which names the gateway, the same writer is the gateway's
  entry and the gateway extension references it.

Finding 6 decision: documented, not refused. Under gatewayForOwnWrites an
application without a build is merely the assembler, which the guide always
admits (omission, mapping.md:132); the role compares the build the event
froze, so a configuration-time check on the live producer could not
guarantee the role either, and a refusal would add a public error case.
ApplicationDevice(bundle:) always states CFBundleVersion. The RolePolicy doc
now states the F3 A3 rule (no build, no revision version or another build:
assembler; build reuse caveat).

HL7 conformance lane (ConformanceFixtureTests):
- `workout` from the guide's workout semantic vector (running, 17:00-17:45
  -07:00, one lap withheld, the watch): the F1 merge gate.
- `heart-rate-classified-writer`: a foreign application classified as one,
  with its writer Device, writer host and Provenance author.
- The goldens no fixture covers: workout-session and every shape this step
  added (workout-session-context, writer-non-ascii-name, gad7-assessment,
  the exporter goldens), written as golden-<name>.json. Left out:
  exporter-deployment-state-of-mind: the HL7 validator rejects any negative
  valence against the pinned guide's own healthkit-state-of-mind-value-domain-1
  (value >= -1 and <= 1): probe valences -0.25 and -1 fail, 0 and 0.25 pass,
  an IG defect. Running every golden through the lane also showed
  insulin-delivery-bolus fails on HKInsulinDeliveryReason, a code the guide's
  healthkit-metadata-key CodeSystem lacks (pre-existing; not added).
- The exporter deployment goldens move their disclosed-UUID system from
  https://study.example.org/fhir/NamingSystem/healthkit-store to
  https://grovealliance.org/fhir/testing/identifiers/native-healthkit-record,
  because the HL7 validator refuses example URLs: the only change in those
  six files (one identifier system each; ECG child has none) and in their
  outlines. A fixture input change of goldens added in 9b2e2d5, no
  converter output change, so their G17 rows keep healthKit 2.

Mutation proof (each applied alone, full GroveHealthKitFHIRTests on macOS,
then reverted; unmutated: 287 total, 285 passed, 2 skipped):
- F11 weakened (`guard governs else { continue }`): fails
  reportsWhatTheKitReports (the mobile Provenance vector is accepted).
- F7, each reverted alone: the instant occurrence (.251600027Z), the period
  end (.999599933Z), the start (.000400066Z), recorded (.251399993Z) fail
  retractionInstantsAreMilliseconds; DocumentReference.date fails
  documentDateIsMilliseconds; the clamp alone removed refuses the
  .distantPast start (invalidInstant).
- the catalog retracts a workout segment besides the session: fails
  workoutRetractionIsExact, workoutExportAndRetraction and the
  retraction-workout golden.
- isSameBuild falls back to the version when no build is stated: fails
  sameBuildIsExact.
- the writer never shares a converter snapshot: fails
  writerEqualToAnUnnamedGatewayIsItsOwnSnapshot (and four golden tests);
  documents naming the gateway again: fails it and
  documentGraphsOmitTheGatewayApplication.

Output diff: none. New golden workout-session-context with its G17 row
(assembler 1, healthKit 2); the exporter deployment goldens change only as
listed; all other goldens byte-identical.

Gates: GroveHealthKitFHIR macOS 287 total, 285 passed, 2 skipped;
swiftlint --strict: no findings; reuse lint: compliant;
Scripts/validate-fhir-conformance.sh healthkit against g3-ig, a clone of
the G2 scratchpad copy of ig-pin with mobile, sensor and healthkit built
(GROVE_FHIR_REF its commit): "Validated 42 producer resource(s) against FHIR
R4", "OK - all healthkit resources conform to their declared R4 profiles",
contract generator and semantic-vector checks clean.

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

Fix-round step G3, audit ledger section 5 confirmed finding 7 (Swift and the
IG kit report different diagnostics for two invalid shapes). Grove's
validator was the one that differed, and it also accepted what the kit
refuses, so it is aligned to the pinned kit (ig-pin
Scripts/producer_validation/profiles.py).

- health-connect-provenance.data-origin-agent: Swift counted only the
  agents typed enterer, required just some identifier, and reported every
  fault at Provenance.entity[0].agent. The kit (profiles.py:663-707) requires
  exactly one agent on the source entity (else .agent), typed enterer and no
  other participant type, an identifier-only Device reference (else
  .agent[0].who), and an identifier under the Android package-name system
  with a non-blank value (else .agent[0].who.identifier). Swift now decides
  and locates it the same way, so an enterer beside an author, an author
  alone, or a package name under another system or blank is refused where
  the kit refuses it.
- A Provenance claiming an adapter's conversion profile beside another
  profile: the kit refuses it without a rule in
  validate_adapter_conversion_provenance (profiles.py, before the
  profile-count rule), so it reports mobile-exchange.unclassified @ Bundle;
  Swift reported mobile-exchange.provenance-profile. The new
  validateAdapterProvenanceClaim reads meta.profile as written and requires
  an adapter conversion profile to be the only claim, before
  validateActiveProvenanceProfile. directProfiles now reads through the same
  writtenProfiles helper.

No Grove producer writes a Health Connect Provenance or a second Provenance
profile (the assembler states one), so wire output is unchanged; only
re-validation verdicts and locations change, toward the kit's.

Tests (AdapterProvenanceGraphTests, kit-computed expectations, same edits
run through exchange_bundle_diagnostics read-only):
- Health Connect Provenance over writer-foreign-application (its author
  agent): data-origin-agent @ .agent[0].who; an enterer under another
  system and one with a blank package name: @ .agent[0].who.identifier; an
  enterer and an author: @ .agent; an enterer referencing a Device: @
  .agent[0].who; a complete data origin: passes the rule and the HealthKit
  outputs then fail adapter-provenance-graph @ Bundle.entry.
- heart-rate-minimal's Provenance claiming the HealthKit and the mobile
  conversion profiles: mobile-exchange.unclassified @ Bundle.
The agents reference the converter's own entry, so a vector has one fault:
an unresolved reference is refused earlier by Swift's reference rule.

Mutation proof (each applied alone, full GroveHealthKitFHIRTests on macOS,
then reverted; unmutated: 287 total, 285 passed, 2 skipped):
- validateAdapterProvenanceClaim not called: fails reportsWhatTheKitReports
  (reported mobile-exchange.provenance-profile @ Provenance.meta.profile).
- a who fault located at .agent: fails it (reported @
  Provenance.entity[0].agent).
- any identifier system accepted: fails it (the wrong-system vector passes
  the rule and reports adapter-provenance-graph @ Bundle.entry).

Output diff: none (goldens byte-identical).

Gates: GroveHealthKitFHIR macOS 287 total, 285 passed, 2 skipped;
GroveSensorKitFHIR 83/83; GroveQuestionnaire 260/260; GroveFHIR 103/103;
GROVE_LOWERED_DEPLOYMENT_TARGETS=1 xcrun swift build --target
GroveFHIRContract: Build complete (the manifest's "2 unhandled files"
warning is pre-existing); swiftlint --strict: no findings; reuse lint:
compliant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fix-round step G3, audit ledger section 2 confirmed finding 8:
ExchangeGraph(validating:kind:) accepted and kept members the FHIR model
never sees, although `json` is documented as the bytes to upload (the
audit injected members into all 252 golden inputs; every one was accepted
and kept). Every rule decides over the decoded model, so such a member
would travel unchecked.

- After the model checks, decodeValidated compares the given bytes with the
  decoded Bundle's own encoding by shape (LosslessJSONValue.shape: every
  scalar blanked, members and elements kept) and refuses a difference with
  .invalidEntries("Serialized event carries members the model does not
  keep") (mobile-exchange.unclassified). Values may differ in lexeme only,
  because the model rewrites decimals such as 72.0; that is why the bytes
  are kept at all. A JSON null member, which the model drops, is refused
  the same way.
- The init(validating:kind:) doc states the refusal and why.
No production caller re-validates (audit), and no shared corpus vector
carries such a member: the gated corpus suite, the questionnaire and
SensorKit suites pass unchanged.

Test (ExchangeGraphBytesTests.revalidationRefusesMembersTheModelDrops): a
Bundle-level "note" member and an "unmodeled" member inside the first
resource are refused; the same graph with its decimal 72 rewritten as 72.0
is accepted and keeps its bytes.

Mutation proof (full GroveHealthKitFHIRTests on macOS, then reverted;
unmutated: 288 total, 286 passed, 2 skipped): without the
validateKeptMembers call the new test fails ("an error was expected but
none was thrown").

Output diff: none (producers build graphs from models; goldens unchanged).

Gates: GroveHealthKitFHIR macOS 288 total, 286 passed, 2 skipped;
GroveSensorKitFHIR 83/83; GroveQuestionnaire 260/260; GroveFHIR 103/103;
GROVE_LOWERED_DEPLOYMENT_TARGETS=1 xcrun swift build --target
GroveFHIRContract: Build complete; swiftlint --strict: no findings; reuse
lint: compliant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lukaskollmer and others added 25 commits October 5, 2026 09:34
Synthesis section 13 F7 (owner-approved; there is no spec file): HealthKit crosses two axes in one
HKCategoryValueAppleWalkingSteadinessEvent case (initialLow, initialVeryLow, repeatLow, repeatVeryLow).
The table stated each as one code (initial-low, ...) that the pinned contract does not admit, so every
walking-steadiness notification was refused with invalidValue(.missingNormativeCode) and the plan compiler
reported its four values as the only compile defects. The guide splits the case in two: "Value is the severity
classification and the notification-occurrence component is whether it is a first or repeat notification."
(mobile/input/data/terminology-reviews.json:4248). The profile requires the component ("component contains
notification-occurrence 1..1 MS", healthkit/input/fsh/generated-measurement-profiles.fsh:2479), and the guide's
HealthkitWalkingSteadinessNotificationExample states exactly that shape (:4445-4447): value
HealthkitWalkingSteadinessNotificationCS#low "Low", component code
healthkit-measurement#walking-steadiness-notification-occurrence, component value
HealthkitNotificationOccurrenceCS#initial "Initial".

What changed:
- CodedTable.Row gains `occurrence`, the notification-occurrence component's result code. The walking-steadiness
  table maps initialLow -> low/initial, initialVeryLow -> very-low/initial, repeatLow -> low/repeat,
  repeatVeryLow -> very-low/repeat.
- ValueRule.coded maps raw values to a new internal CodedValue (the value and the optional occurrence component)
  instead of a bare CodeableConcept; apply() sets the value and, when the value states one, the component. Every
  other coded table states no occurrence, so its Observations are unchanged (the component stays absent, and
  menstrual flow's metadata component is still appended after it).
- The compiler builds the component once per value from the contract: code = the contract component's coding
  (no display, as every component code), value = the contract's published result with its display. A contract
  without a coded notification-occurrence component, or one that does not admit the row's occurrence, refuses
  the type as not yet convertible, like any component a rule reads; a classification the contract does not
  admit still leaves just that value unresolved (the value-level defect policy is kept).
- HealthKitContentPlan.compileDefects is now empty; the plan doc says CI keeps it empty.
- HealthKitAssembly.outputRevision goes from 8 to 9: four inputs that were refused now yield graphs.
- .missingNormativeCode is now unreachable with the generated contracts; it stays, per the owner decision to
  remove unreachable public error cases only in the final cleanup. No public API change.

Tests:
- HealthKitContentPlanTests: compileDefectsAreEmpty replaces the known-set pin of the four walking-steadiness
  defects.
- HealthKitCategoryConversionTests.walkingSteadinessNotification: all four cases end to end, value
  code/display/system and the single component's code (no display) and value code/display/system, against the
  generated contract.
- HealthKitContentCompilerTests: the contract without its notification-occurrence component, and the component
  without the result "repeat", each refuse the type with exactly that defect; a contract without "very-low"
  leaves values 2 and 4 unresolved and converts values 1 and 3 with their occurrence.
- New golden walking-steadiness-notification (observations group, sequence 19, uuid(19)): initialLow over one
  day under America/Los_Angeles on the fixture watch; the Observation is the guide example's shape. It joins the
  HL7 lane's validated goldens (ConformanceFixtureTests.validatedGoldens), since no fixture covers the shape.
- Mutation proof (each applied alone, full GroveHealthKitFHIRTests on macOS, reverted): dropping the component
  assignment fails the end-to-end test, the golden and both corpus suites (assembly and exporter); mapping
  initialLow to "repeat" fails the same four; restoring the old one-code table fails 12 tests including
  compileDefectsAreEmpty, the three compiler tests, the golden, both corpus suites and the conformance fixture
  writer.

Output diff (goldens and corpus regenerated outside the worktree through TEST_RUNNER_GROVE_GOLDEN_OUTPUT_DIR and
TEST_RUNNER_GROVE_CONTENT_CORPUS_OUTPUT_DIR, then copied in):
- Goldens: new walking-steadiness-notification.json; outlines.json only gains that case's entry. The other 57
  golden files are byte-identical. G17: a new row and the outlines row's new digest, both under healthKit 9.
- Content corpus (content-corpus.changes.txt): 3,437 lines checked in; added 0, removed 0, duplicated 0,
  restated inputs 0, changed outputs 4, exactly the convert vectors
  category/HKCategoryTypeIdentifierAppleWalkingSteadinessEvent/{1,2,3,4}: refused
  mobile-input.value-shape-invalid invalidValue(.appleWalkingSteadinessEvent, .missingNormativeCode) at
  HKSample.value -> one Observation graph (Observation, Device, Device, Provenance; no warnings) whose value is
  low "Low" / very-low "Very low" / low / very-low and whose one component is initial "Initial" / initial /
  repeat "Repeat" / repeat. An independent Python oracle derived each expected graph from the checked-in
  low-cardio-fitness vector of the same inputs (only code, profile, source type, value and component replaced;
  identifiers and the envelope digest left out) and matched all four, with every other line byte-identical. The
  emitted source-output identifier equals the one the unchanged retraction vector of the type already targets.
  Unchanged: raw values -1, 0 and 5-8 (still mobile-input.unsupported-source-value), the type's catalog, output and
  retraction vectors, and every other vector.

Gates: GroveHealthKitFHIR macOS suite passed (359 tests, 357 passed, 2 skipped, 0 failed); swiftlint --strict on
GroveFHIRContract, GroveHealthKitFHIR and GroveHealthKitFHIRTests clean; reuse lint compliant. GroveFHIRContract
and the generator are untouched, so gate (3) does not apply. The HL7 validator lane was not run here (the pinned
guides are not built in this environment).

Size (A): code 10,396 (+691), public 1,008 (+135) against baseline 9b086e6; this commit +22 code lines
(+10 doc lines), +0 public, +0 package declarations against ae381a2.

Integration (FX round onto lukas/healthkit-content-rewrite after FX-proleptic-reverse, FX-time-ig, FX-F4-percent,
FX-F5-interval-period, FX-F6-codes, FX-F9-bp-members and FX-F10-metadata-warnings): the bump is renumbered 3 -> 4 to
8 -> 9, so the new golden's row and the outlines row carry healthKit 9. Conflicts resolved keeping both sides:
ValueRule.apply keeps FX-F6-codes' untyped throws (a quantity may rethrow GroveFHIRDecimalError) and states the
occurrence component; the golden case list holds sequences 17, 18 and 19. Goldens and the corpus regenerated outside
the worktree equal the checked-in files (58 goldens; outlines.json differs from the integrated parent only by the new
case; corpus 3,437 lines, the 4 changes above, each equal to its branch recording). GroveHealthKitFHIR macOS on this
tree: 429 tests, 427 passed, 2 skipped, 0 failed.

Integration (onto the cleanup, lukas/grove-fhir-rework-integration after 5453c80): the revision keeps its number
(8 -> 9); the new golden's row and the outlines row carry healthKit 9. Conflicts: ValueRule.apply's coded case takes
this commit's body under the cleanup's spelling HealthKitConversionError.ValueFailure, and the golden case list keeps
blood-pressure-member-metadata (18) beside this case (19), built through GoldenFixtures.export. Ported with every
assertion kept: the category test exports through ExporterFixtures.export(_:_:) instead of the deleted
HealthKitConverter. Regenerated outside the worktree on this tree: walking-steadiness-notification.json is
byte-identical to this commit's on its branch, outlines.json only gains its case (57 golden files here), and the
corpus (3,434 lines) changes exactly the 4 vectors above and nothing else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… Lookup by Id

Review fixes for 95130856 (synthesis section 13 F7; there is no spec file). Tests only; no Sources change.

Finding (claude, low, test-coverage), confirmed: nothing tested that a coded value the contract does not
admit is refused at runtime as invalidValue(.missingNormativeCode). Before F7 the four walking-steadiness corpus
vectors were its only coverage; they are graphs now, no Swift test named .missingNormativeCode, and
unadmittedClassificationIsUnresolved checked only the compiled `values` and `unresolved` sets, so its own claim
("converts values 1 and 3 with their occurrence") was not exercised. The mechanism stays (the value-level defect
policy is kept, and the public case stays until the final cleanup per the owner decision).

What changed (HealthKitContentCompilerTests):
- unadmittedClassificationIsUnresolved now applies the plan compiled from the contract without "very-low" to
  stored walking-steadiness samples: initialVeryLow and repeatVeryLow throw .missingNormativeCode; raw 5, which the
  table does not map, still throws .unsupportedValue(5) beside the unresolved values; initialLow and repeatLow convert
  to value low with occurrence initial and repeat. The structural checks it replaces are implied by these.
- New occurrenceComponentIsFoundByIdentifier: a contract listing a decoy component (the occurrence component under
  id and code "decoy") ahead of notification-occurrence compiles without defects, and a repeatLow notification states
  exactly the notification-occurrence component's code. The generated contract has one component, so nothing pinned
  the id filter before.
- A notification(_:in:) helper builds the stored sample (HealthKit refuses an undefined value at creation) and runs
  the compiled ObservationPlan with the bridged metadata; ComponentContract.with gains a `code:` parameter.

Surviving mutants, each now killed (applied alone to Sources, HealthKitContentCompilerTests on macOS, reverted):
- r09-unresolved-as-unsupported (HealthKitObservationContent.swift:164, `throw .unsupportedValue(raw)`): fails
  unadmittedClassificationIsUnresolved (".unsupportedValue(2)" thrown instead of ".missingNormativeCode").
- r05-drop-component-id-filter (HealthKitContentPlan+Compile.swift notificationOccurrence,
  `contract.components.first`): fails occurrenceComponentIsFoundByIdentifier (component code "decoy").
- Added converse r09b (`throw .missingNormativeCode` for every unmapped value): fails
  unadmittedClassificationIsUnresolved (".missingNormativeCode" thrown instead of ".unsupportedValue(5)").

Output diff: none. No golden, outlines.json or corpus file changes; Sources are byte-identical to 95130856.

Gates: GroveHealthKitFHIR macOS suite passed (360 tests: 358 passed, 2 skipped, 0 failed); swiftlint --strict on
GroveFHIRContract, GroveHealthKitFHIR and GroveHealthKitFHIRTests clean; reuse lint compliant. GroveFHIRContract and
the generator are untouched, so gate (3) does not apply.

Size (A): code 10,696 (+991), public 1,012 (+139) against baseline 9b086e6, on the integrated FX round; unchanged
by this commit (tests only). This item, FX-walking-steadiness, adds 22 code lines and no public declaration to the
10,674 / 1,012 it started from (alone on ae381a2 it read 10,396 / 1,008).

Integration (onto the cleanup, lukas/grove-fhir-rework-integration after 5453c80): applied without textual
conflicts; the new pins spell HealthKitValueFailure as the cleanup nests it, HealthKitConversionError.ValueFailure.
Tests only, so goldens, outlines.json and the corpus are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FX-ecg-frequency-ig, first of two commits: pin today's verdicts before the fix
changes them. The pinned guide (catalog/healthkit-adapter.json, ECG
admissionRule) compares the stated sampling frequency and 1000 /
SampledData.period as shortest round-trip decimals; the waveform check
instead requires Decimal(hertz) * period == 1000. The two differ for a
frequency whose decimal reciprocal does not terminate, and wherever
Foundation's Decimal(Double) does not state the binary64 exactly. The corpus
had no vector where they differ: every sampling-frequency vector used the
base reading's 2 ms period.

What changed (tests only): ContentCorpusGrid gains reciprocalFrequencies,
three ECG readings beside the existing sampling-frequency vectors. The base
reading's offset rewrite moves from a local function of waveformEdges into
ContentCorpusGrid.reading(offsets:), which both use; the offsets/* vectors
state the same inputs.

Output diff: none for any existing vector; no source change, so all goldens,
outlines.json and every existing corpus line are unchanged. Content corpus,
regenerated through GROVE_CONTENT_CORPUS_OUTPUT_DIR (3,437 -> 3,440 lines;
changes report: added 3, removed 0, restated 0, changed outputs 0). Each new
vector is today's refusal, healthkit-input.ecg-evidence,
ecgEvidence(samplingFrequencyMismatch), at HKElectrocardiogram:
- added electrocardiogram/sampling-frequency/reciprocal: offsets 0.25, 0.253,
  0.256, 0.259 s (a 3 ms period) at 1000/3 Hz (333.3333333333333). Refused
  because no decimal times 3 is 1000.
- added electrocardiogram/sampling-frequency/reciprocal-next: the same period
  at the binary64 one step above (333.33333333333337).
- added electrocardiogram/sampling-frequency/exact-quotient: offsets 0.25,
  0.25131072, 0.25262144, 0.25393216 s (1.31072 ms) at 762.939453125 Hz, the
  exact quotient. Refused because Decimal(762.939453125) is
  762.9394531249998848, whose product with the period is not 1000.

Gates: the GroveHealthKitFHIR macOS suite passed in generation mode (357
tests, 353 passed, 4 skipped: the three corpus verification tests, which
generation disables, and the throughput benchmark); the checked-in corpus
is the regenerated file byte for byte. swiftlint --strict clean.

Integration (FX round): applied without conflicts; the corpus regenerated outside the worktree on this tree equals
the checked-in file (3,437 -> 3,440 lines, the 3 added vectors equal to their branch recordings).

Integration (onto the cleanup, lukas/grove-fhir-rework-integration after 5453c80): the corpus conflicted only
because the cleanup re-renders it through the exporter. Regenerated outside the worktree on this tree (3,434 ->
3,437 lines here), it adds exactly the 3 vectors above and changes nothing else, each equal to this commit's
recording carried through the cleanup's rendering. Goldens (57 files) and outlines.json unchanged.

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

FX-ecg-frequency-ig (Codex review of M4, finding 3; synthesis section 20
had listed it as unchanged on purpose). The pinned guide's ECG admissionRule
(catalog/healthkit-adapter.json:242) says: "Canonicalize samplingFrequency
and computed 1000 / SampledData.period to the producer's shortest
round-trip base-10 decimal representation and require exact decimal
equality (tolerance zero)." The waveform check required
Decimal(hertz) * period == 1000 instead. That refuses every frequency whose
decimal reciprocal does not terminate (1000/3 Hz at a 3 ms period), and it
depends on Foundation's Decimal(Double), which does not state every binary64
exactly: Decimal(762.939453125) is 762.9394531249998848, so the exact
quotient of a 1.31072 ms period was refused as well.

What changed:
- HealthKitECGContent.Waveform.requireFrequency compares hertz with
  Double((1_000 / period).description). Equal shortest round-trip decimals
  are equal binary64 values, so the guide's text comparison is binary64
  equality with the quotient's nearest binary64. "Computed 1000 /
  SampledData.period" is read as the quotient of the exact decimal period
  (the wire SampledData.period), rounded once to binary64. It is not 1000
  divided by the period's own binary64, which rounds twice: that would
  refuse exact agreements such as 762.939453125 Hz at 1.31072 ms (1000 /
  1.31072 in binary64 is 762.9394531249999), against mapping.md:66
  ("must agree exactly with the SampledData period"). Decimal divides to at
  least 37 significant digits less the period's, and a binary64 rounding
  boundary other than the quotient itself lies at least
  1 / (significand * 2^54) away relative to it, so for every period of at
  most ten significant digits the parse lands on the exact quotient's
  nearest binary64. HealthKit itself writes ECGs only from Apple Watch at
  512 Hz (1.953125 ms), where both rules agree. The check order (count,
  offsets, period, frequency, voltages) and both error cases are unchanged.
  requireFrequency becomes internal, like requireCount, as a test seam.
- HealthKitAssembly.outputRevision 9 -> 10 (this item): equal inputs now
  yield graphs where they were refused.
- Tests: HealthKitECGContentTests admits 1000/3 Hz at 3 ms, 762.939453125 Hz
  at 1.31072 ms and 512 Hz at 1.953125 ms through the waveform, and refuses
  the binary64 one step above 1000/3 Hz. A sweep covers every period of at
  most four significant digits from 0.0001 ms to 9999 ms (49,995) and 1,313
  ten-digit ones: exactly the binary64 nearest 1000 / period is admitted and
  both its binary64 neighbours are refused, measured against the division of
  the exact power of ten by the exact significand (one rounding).

Output diff: no golden changed (every ECG golden states 500 Hz at 2 ms,
which both rules admit; G17 rows unchanged), outlines.json unchanged.
Content corpus, regenerated through GROVE_CONTENT_CORPUS_OUTPUT_DIR (3,440
lines; changes report: added 0, removed 0, restated 0, changed outputs 2):
- changed electrocardiogram/sampling-frequency/reciprocal: the refusal
  healthkit-input.ecg-evidence, ecgEvidence(samplingFrequencyMismatch), at
  HKElectrocardiogram becomes the ECG graph (waveform and average heart
  rate), SampledData.period 3, effectivePeriod 2026-08-17T15:30:00.25-07:00
  to 2026-08-17T15:30:00.259-07:00.
- changed electrocardiogram/sampling-frequency/exact-quotient: the same
  refusal becomes the ECG graph, SampledData.period 1.31072, effectivePeriod
  end 2026-08-17T15:30:00.25393216-07:00.
- Unchanged: sampling-frequency/reciprocal-next (333.33333333333337 Hz,
  still samplingFrequencyMismatch), mismatch (250 Hz), near (499.99 Hz),
  zero, nan, negative, none, both 250 Hz precedence pairs, and every other
  vector.
An input the old rule admitted is refused now only if Foundation's
Decimal(hertz) is the exact quotient of a terminating period yet lies more
than half a binary64 step from hertz; no vector, golden or test states one.

Gates: GroveHealthKitFHIR macOS suite passed in verification mode (359
tests, 357 passed, 2 skipped: the throughput benchmark and the corpus
regeneration); the sweep takes 0.55 s. swiftlint --strict clean (Sources/
GroveFHIRContract, Sources/GroveHealthKitFHIR,
Tests/GroveHealthKitFHIRTests); reuse lint clean. GroveFHIRContract and the
generator are unchanged, so gate 3 does not apply.

Size (A): code 10,374 (+669), public 1,008 (+135), against baseline
9b086e6. FX-ecg-frequency-ig leaves both unchanged against ae381a2 (the
rule is one replaced line; GroveHealthKitFHIR doc lines 1,018 -> 1,025).

Integration (FX round onto lukas/healthkit-content-rewrite after FX-proleptic-reverse, FX-time-ig, FX-F4-percent,
FX-F5-interval-period, FX-F6-codes, FX-F9-bp-members, FX-F10-metadata-warnings and FX-walking-steadiness): the bump
is renumbered 3 -> 4 to 9 -> 10. The test helper's doc conflict kept both sides (FX-F6-codes' average heart rate
parameter, this item's 3 ms points). Goldens regenerated byte-identical (58); the corpus (3,440 lines) changes the
two vectors above, composed with FX-F10-metadata-warnings: each admitted ECG graph also states that item's warning
"mobile-omission.unmodeled-metadata(HKTimeZone)", as every ECG whose record names a zone does (the ECG states the
offset, not the zone's name). GroveHealthKitFHIR macOS on this tree: 432 tests, 430 passed, 2 skipped, 0 failed.

Integration (onto the cleanup, lukas/grove-fhir-rework-integration after 5453c80): the revision keeps its number
(9 -> 10). The corpus conflicted only because the cleanup re-renders it through the exporter; regenerated outside
the worktree on this tree (3,437 lines), it changes exactly the 2 vectors above and nothing else, checked per vector
against this commit's own change carried through the cleanup's rendering. Goldens (57 files) and outlines.json
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FX-ecg-frequency-ig review fix (finding R1, surviving mutant m11). The
waveform's frequency guard refuses a stated frequency that is not finite or
not positive as invalidSamplingFrequency before it compares it with
1000 / period. The corpus pinned zero, NaN and negative, but not +infinity.
NaN fails `hertz > 0` on its own, so dropping `hertz.isFinite` (mutant m11)
passed all 359 tests while +infinity reached the comparison and was refused
as samplingFrequencyMismatch instead. The guard is unchanged; this pins it.

What changed (tests only): ContentCorpusGrid.electrocardiograms gains
("infinity", .infinity) beside the existing sampling-frequency edges. The
list is wrapped onto its own lines to stay within the line-length limit.

Output diff: no source change, so all goldens and outlines.json are
unchanged. Content corpus, regenerated through
GROVE_CONTENT_CORPUS_OUTPUT_DIR (3,440 -> 3,441 lines; changes report: added
1, removed 0, duplicated 0, restated inputs 0, changed outputs 0):
- added electrocardiogram/sampling-frequency/infinity: the base reading (2 ms
  period) stating samplingFrequency "Infinity", refused as
  healthkit-input.ecg-evidence, ecgEvidence(invalidSamplingFrequency), at
  HKElectrocardiogram.
Every existing line is byte-identical.

Mutation check: with m11 applied, ContentCorpusTests.everyLineIsReproduced
fails on exactly this vector (golden invalidSamplingFrequency, actual
samplingFrequencyMismatch).

Gates: the GroveHealthKitFHIR macOS suite passed in generation mode (359
tests, 355 passed, 4 skipped: the corpus verification tests, which generation
disables, and the throughput benchmark). It passed again in verification mode
on the tree that also holds the next commit (360 tests, 358 passed, 2
skipped). swiftlint --strict clean; reuse lint clean. GroveFHIRContract and
the generator are unchanged, so gate 3 does not apply.

Integration (FX round): the grid's edge lists conflicted with FX-F6-codes' added subnormal average heart rate;
both are kept. The corpus regenerated outside the worktree equals the checked-in file (3,440 -> 3,441 lines, the
added vector equal to its branch recording).

Integration (onto the cleanup, lukas/grove-fhir-rework-integration after 5453c80): the corpus conflicted only
because the cleanup re-renders it through the exporter; regenerated outside the worktree on this tree (3,437 ->
3,438 lines here), it adds exactly the 1 vector above, equal to this commit's recording carried through the
cleanup's rendering, and changes nothing else. Goldens (57 files) and outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FX-ecg-frequency-ig review fix (surviving mutant m15). The context
fingerprint carries HealthKitAssembly.outputRevision, so an output that
changes for equal inputs must bump it. G17 ties every golden to the revision
its bytes were last changed under, but nothing tied the content corpus to a
revision. Revisions 3 (993efcc, proleptic Gregorian) and 4 (29fc3cd1,
FX-ecg-frequency-ig) were bumped for corpus output changes alone: no golden
changed, and every G17 row names revision 1 or 2. Lowering the revision from
4 to 3 (mutant m15) therefore passed every test. The reviewer judged that no
test could pin the bump. One can: the corpus gets a G17 row of its own.

What changed (tests only):
- GoldenOutputRevisionTests.contentCorpus pins the SHA-256 of the checked-in
  content-corpus.jsonl with assembler revision 1 and HealthKit revision 10, the
  revision at which a corpus output last changed. The new test "G17: the
  content corpus's bytes match its row, whose revisions the code has reached"
  checks it with the same rule as the goldens. A changed corpus fails until the
  row is updated. The row must carry a bumped revision when an output changed
  (review checks this in the diff). A corpus that only gains vectors updates
  its digest without a bump, as a new golden adds its row without one.
- The digest and revision checks move into one helper, shared by the golden
  rows and the corpus row. The failure message now reads "update its row,
  with the bumped output revision if an output changed". The suite's and
  GoldenRevision's doc comments state the corpus rule.

Output diff: none. No source change, so all goldens, outlines.json and the
content corpus (3,441 lines, as the previous commit left it) are unchanged.

Mutation check: with m15 applied (outputRevision 4 -> 3), the new test fails
(row.healthKit 4 > HealthKitAssembly.outputRevision 3). Restoring the source
makes it pass again.

Gates: the GroveHealthKitFHIR macOS suite passed in verification mode (360
tests, 358 passed, 2 skipped: the throughput benchmark and the corpus
regeneration). swiftlint --strict clean (Sources/GroveFHIRContract,
Sources/GroveHealthKitFHIR, Tests/GroveHealthKitFHIRTests); reuse lint clean.
GroveFHIRContract and the generator are unchanged, so gate 3 does not apply.

Size (A): code 10,696 (+991), public 1,012 (+139), against baseline
9b086e6, on the integrated FX round. Unchanged against 29fc3cd1 (both commits
change tests only); this item, FX-ecg-frequency-ig, changes no code line or
public declaration (alone on ae381a2 it read 10,374 / 1,008).

Integration (FX round): applied without conflicts. The paragraph above describes the item's branch; on the
integrated branch the revisions bumped for corpus output changes alone are 3 (proleptic Gregorian), 4 (FX-time-ig)
and 10 (this item), and the golden rows reach revision 9. The row therefore pins the integrated corpus (3,441 lines)
under healthKit 10, the revision at which a corpus output last changed (29fc3cd1's integrated commit); the mutation
of this item becomes outputRevision 10 -> 9. GroveHealthKitFHIR macOS on this tree: 433 tests, 431 passed, 2
skipped, 0 failed.

Integration (onto the cleanup, lukas/grove-fhir-rework-integration after 5453c80): the table's doc keeps the
cleanup's rule beside this commit's (a golden whose case changed its inputs while the output for equal inputs stayed
the same adds no bump), extended to the corpus: one that only gains vectors, or renders equal outputs another way
(the cleanup re-rendered it through the exporter), updates its digest without a bump. The content-corpus row pins
the integrated corpus's digest under healthKit 10, the revision at which a corpus output last changed here too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FX-route-invalid-accuracy (IG compliance; flagged by the M6 fixer as surviving mutant X4, pinned by c1e392d).
CoreLocation marks a fix whose latitude and longitude are invalid with a negative horizontal accuracy. The
location track wrote such a fix as a position with horizontalAccuracy "-1".

What the pinned IG (eff5af8f) states for the route's recording format, location-track-samples
(catalog/format-registry.json, sensor formats page):
- the payload has "one row per source sample in source order", so a fix cannot be left out;
- horizontalAccuracy is not nullable ("`no` requires a non-empty field in every data row"), unlike
  verticalAccuracy ("empty when altitude is invalid") and the speed and course columns ("empty when
  unavailable"), so it cannot be written empty;
- horizontalAccuracy is the "Radius of uncertainty for the horizontal position in metres" and latitude and
  longitude are WGS 84 degrees, so "-1" states a negative radius for a coordinate the source calls invalid,
  in a source-neutral schema whose reader cannot know CoreLocation's marker.
No row can state such a fix, so the route is refused: DocumentPlan.locationTrack throws
HealthKitValueFailure.outsideDomain for the first fix whose horizontal accuracy is negative, in source order,
after the empty-route check. The IG registers no route-specific diagnostic; mobile-input.value-outside-domain
("A numeric source value is ... outside the measurement's inclusive value domain") is the closest, at the
generic value location HKSample.value. A nonfinite accuracy still reaches the CSV writer as before. A route
under RouteDisclosurePolicy.omit is still dropped before its fixes are read, and the exporter's companion
fingerprint of a refused route is "unserializable", as for any route the assembly refuses. Vertical accuracy
needs no change: a negative one is already written empty, as the registry states.

Output diff: HealthKitAssembly.outputRevision 10 -> 11. No golden changed (golden G17 rows unchanged; the goldens'
route fixes all state a non-negative horizontal accuracy). Content corpus, regenerated through
GROVE_CONTENT_CORPUS_OUTPUT_DIR (3,441 -> 3,443 lines; changes report: added 2, removed 0, duplicated 0,
restated inputs 0, changed outputs 1):
- changed workout-route/negative-horizontal-accuracy: the graph whose track carried "-1" in the second fix's
  horizontalAccuracy is now refused with mobile-input.value-outside-domain, invalidValue(workoutRoute,
  outsideDomain), at HKSample.value.
- added workout-route/precedence/omission-before-invalid-coordinate: the same fixes under the omit default; the
  route is omitted, not refused.
- added workout-route/precedence/invalid-coordinate-before-sync: the same fixes with a broken sync pair; the
  content refusal is reported, not the sync failure.
Every other vector is unchanged.

Tests: HealthKitRecordingDocumentTests pins the refusal under authorization and the omission under the default
policy; the corpus grid's doc comment now states the refusal.

Gates: GroveHealthKitFHIR macOS suite passed (358 tests, 356 passed, 2 skipped) on this tree in verification
mode, after the regeneration run (354 passed, 4 skipped); swiftlint --strict clean; reuse lint clean.

Size (A): code 10,377 (+672), public 1,008 (+135) against baseline 9b086e6; this commit +3 code lines, +0 public
declarations (against ae381a2: code 10,374, public 1,008).

Integration (FX round onto lukas/healthkit-content-rewrite after the nine items before it): the bump is renumbered
3 -> 4 to 10 -> 11, and FX-ecg-frequency-ig's content-corpus G17 row takes the regenerated corpus's digest under
healthKit 11, since a corpus output changed. The corpus regenerated outside the worktree (3,441 -> 3,443 lines)
changes exactly the vectors above, each equal to its branch recording; workout-route/negative-horizontal-accuracy
also drops the "mobile-omission.unmodeled-metadata(HKTimeZone)" warning FX-F10-metadata-warnings gave its graph, as
the graph is now refused. Goldens regenerated byte-identical (58). GroveHealthKitFHIR macOS on this tree: 434 tests,
432 passed, 2 skipped, 0 failed.

Integration (onto the cleanup, lukas/grove-fhir-rework-integration after 5453c80): the revision keeps its number
(10 -> 11), and the content-corpus row takes the integrated corpus's new digest under healthKit 11. The location
track throws the cleanup's HealthKitConversionError.ValueFailure.outsideDomain. Ported with both assertions kept: the
route test exports through ExporterFixtures, so the authorized route's refusal is the exporter's
.invalidValue(.workoutRoute, .outsideDomain), which wraps the failure the deleted HealthKitAssembly.convert(_:context:)
seam threw, and the omitted route delivers no export. Regenerated outside the worktree on this tree (3,438 -> 3,440
lines here; 57 golden files and outlines.json unchanged), the corpus adds the 2 vectors and changes the 1 above and
nothing else, checked per vector against this commit's own change carried through the cleanup's rendering.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FX-route-invalid-accuracy, review fixes (R1-R3 on 62491c9d). The route stays refused: the pinned IG's
location-track-samples schema (catalog/format-registry.json) writes "one row per source sample in source
order" and horizontalAccuracy, the "Radius of uncertainty for the horizontal position in metres", is not
nullable, so no row can state a fix CoreLocation marks invalid. Only the registered code changes.

R1 (code). 62491c9d reported the refusal as mobile-input.value-outside-domain. That rule names "A numeric
source value is nonfinite, outside the measurement's inclusive value domain, or fractional where the
contract admits only integers" (catalog/exchange-protocol.json). A workout route states no measurement
(healthkit-adapter.json HKWorkoutRouteTypeIdentifier: "measurementIDs": []), and the registry declares no
domain for the column beyond the CSV encoding's "numberValueDomain": "finite-ieee754-binary64", which -1
satisfies. The owner-reconciled spec F6 reserves value-outside-domain for nonfinite values and violations of
a declared domain and sends every other finite refusal to the fallback (A4; it rejected an implied domain for
ECG heart rate). Conformance/README.md: "`mobile-input.unclassified` as the fallback for a reason no more
specific rule names". DocumentPlan.locationTrack now throws the internal WorkoutRouteFailure.invalidCoordinate,
which the existing default arm of HealthKitConversionError(conversionFailure:) reports as .dependency, code
mobile-input.unclassified at "Bundle", the path F6 accepted for its own residue. No public API is added;
HealthKitValueFailure.outsideDomain has no thrower again until FX-F6-codes lands.

R2/R3 (tests). Every invalid fix in the tests was the last fix and also had verticalAccuracy -1, no fixture
had horizontalAccuracy 0, and no route value was nonfinite, so `<= 0`, last-fix-only, `&& verticalAccuracy < 0`
and refusing NaN in the check all passed. New corpus vectors pin each edge; the unit test now uses a first fix
with a valid altitude and pins the diagnostic. The NaN behaviour stays as it was (the writer's refusal).

Output diff: graph bytes unchanged, so HealthKitAssembly.outputRevision stays 11 (62491c9d's integrated
commit). No golden changed. Content corpus regenerated through GROVE_CONTENT_CORPUS_OUTPUT_DIR (3,443 ->
3,446 lines; changes report: added 3, removed 0, duplicated 0, restated inputs 0, changed outputs 2):
- changed workout-route/negative-horizontal-accuracy and workout-route/precedence/invalid-coordinate-before-sync:
  code mobile-input.value-outside-domain -> mobile-input.unclassified, location HKSample.value -> Bundle,
  error invalidValue(workoutRoute, outsideDomain) ->
  dependency(HealthKitDependencyFailure(underlying: WorkoutRouteFailure.invalidCoordinate)).
- added workout-route/negative-horizontal-accuracy-first: the first fix has horizontalAccuracy -1 and
  verticalAccuracy 3; refused unclassified at Bundle as above.
- added workout-route/zero-horizontal-accuracy: a 0 m radius is a reading; the second row writes
  "1787005801,37.4276,-122.1698,0,0,,,,,".
- added workout-route/nonfinite-horizontal-accuracy: NaN reaches the writer as before; refused unclassified at
  Bundle with dependency(RecordingCSVWriter.WriterError.nonFiniteNumber(column: "horizontalAccuracy")).
Every other vector is unchanged.

Mutations (ContentCorpusTests + HealthKitRecordingDocumentTests, macOS), each now fails: `< 0` -> `<= 0`
(zero-horizontal-accuracy), last fix only and `&& verticalAccuracy < 0` (negative-horizontal-accuracy-first and
the unit test), `!(>= 0)` (nonfinite-horizontal-accuracy), and reverting to outsideDomain (corpus code and the
unit test). outputRevision 4 -> 3 still passes by design: the literal is not pinned, the fingerprint tests vary
it relative to itself.

Gates: GroveHealthKitFHIR macOS suite passed in verification mode (358 tests, 356 passed, 2 skipped) after the
regeneration run (358 tests, 354 passed, 4 skipped); swiftlint --strict clean; reuse lint clean.

Size (A): code 10,702 (+997), public 1,012 (+139)
Against baseline 9b086e6, on the integrated FX round; this commit +3 code lines, +0 public declarations; this item,
FX-route-accuracy, adds 6 code lines to the 10,696 / 1,012 it started from (alone on ae381a2 it read 10,380 /
1,008). The FX round adds 328 code lines and 4 public declarations (two HealthKitSampleProjectionError cases) to
ae381a2's 10,374 / 1,008.

Integration (FX round onto lukas/healthkit-content-rewrite; this is its last commit): only the corpus conflicted;
outputRevision stays 11 (FX-route-accuracy's bump), and the content-corpus G17 row takes the regenerated corpus's
digest under healthKit 11. FX-F6-codes has landed, so HealthKitValueFailure.outsideDomain keeps its throwers, and the
corpus row now pins the bump: lowering outputRevision to 10 fails it. The corpus regenerated outside the worktree
(3,443 -> 3,446 lines) changes exactly the vectors above, each equal to its branch recording, except the added
workout-route/zero-horizontal-accuracy, whose graph also states FX-F10-metadata-warnings'
"mobile-omission.unmodeled-metadata(HKTimeZone)" (a document has no element for the zone).

The FX round's output diff against ae381a2 (checked by script against every item's branch head): goldens 56 -> 59
files, adding body-fat-percentage-fraction, blood-pressure-member-metadata and walking-steadiness-notification and
changing heart-rate-interval, each byte-identical to its item's branch; outlines.json adds those three cases and
changes the eight FX-F10-metadata-warnings outlines, each equal to its item's branch. Content corpus 3,339 -> 3,446
lines (added 107, changed 859, removed 0): no vector changed outside the items' own changes; 952 equal the one item
that changed them; 8 compose one item's change with another item's rule (listed in the integration notes of
FX-F6-codes, FX-F10-metadata-warnings, FX-ecg-frequency-ig, FX-route-accuracy and this commit); 6 were changed by two
items (blood-pressure members by FX-F9 and FX-F10, the 1500 Los Angeles ECG by FX-time-ig and FX-F10, the
negative-accuracy route by FX-F10 and FX-route-accuracy, each ending as the item that refuses or decides it; the
blood-pressure catalog entry carries FX-F5's withheld endDate and FX-F9's member dispositions).
G17: HealthKitAssembly.outputRevision 3 -> 11, one bump per item that changes output (FX-time-ig 4, FX-F4-percent 5,
FX-F5-interval-period 6, FX-F9-bp-members 7, FX-F10-metadata-warnings 8, FX-walking-steadiness 9,
FX-ecg-frequency-ig 10, FX-route-accuracy 11; FX-proleptic-reverse and FX-F6-codes change no emitted graph bytes).

Gates on this head: GroveHealthKitFHIR macOS (Scripts/run-package-tests.sh) 434 tests, 432 passed, 2 skipped, 0
failed; swiftlint --strict clean on GroveFHIRContract, GroveHealthKitFHIR and its tests; GroveFHIRContract changed in
this round (ExchangeInstant.days(fromYear:month:day:)), so GroveSensorKitFHIR macOS 85 passed, GroveQuestionnaire
macOS 260 passed, GroveFHIRContract builds at the iOS 15 floor, and the generator --check against the pinned catalog
passes; reuse lint compliant.

Integration (onto the cleanup, lukas/grove-fhir-rework-integration after 5453c80): outputRevision stays 11, and the
content-corpus row takes the integrated corpus's digest under healthKit 11. The location track throws this commit's
WorkoutRouteFailure.invalidCoordinate, which the cleanup's narrowing reports as .dependency, so the vectors render
their error as dependency("GroveHealthKitFHIR.WorkoutRouteFailure") (the cleanup names the type alone). Ported with
every assertion kept: the route test exports through ExporterFixtures and pins the refusal as the narrowing of
WorkoutRouteFailure.invalidCoordinate, unclassified at "Bundle"; the omitted route delivers no export. Regenerated
outside the worktree on this tree (3,440 -> 3,443 lines here; 57 golden files and outlines.json unchanged), the
corpus adds the 3 vectors and changes the 2 above and nothing else, checked per vector against this commit's own
change carried through the cleanup's rendering.

The FX round on the integration branch (ae381a2..19144c13 picked onto 5453c80; 31 commits, each compiled with
build-for-testing of the GroveHealthKitFHIR test plan and swiftlint --strict clean). Output diff against 5453c80,
checked by script (fxi/union_check.py) as the cleanup's state plus exactly the union of the items' changes, each
carried through the cleanup's oracle rendering; nothing lies outside it:
- Goldens: 54 -> 57 graph files: body-fat-percentage-fraction, blood-pressure-member-metadata and
  walking-steadiness-notification added and heart-rate-interval changed, each byte-identical to its item's branch.
- outlines.json: the three new cases, the eight FX-F10 outlines gaining
  mobile-omission.unmodeled-metadata@HKSample.metadata, and blood-pressure-member-metadata's warning located there.
- Content corpus: 3,336 -> 3,443 lines (added 107, changed 796, removed 0): the 966 vectors the items changed, of
  which 66 render unchanged here (56 catalog entries differ only in the deleted field dispositions; 10 retractions
  of types without outputs stay {"nothingToRetract": true}), plus 3 cleanup vectors whose existing unmodeled-metadata
  warning FX-F10 moved to HKSample.metadata. Against ae381a2: 3,339 -> 3,443 lines (added 107, removed 3,
  changed 1,595, every change the cleanup's or the round's).
- G17: HealthKitAssembly.outputRevision 3 -> 11, the round's numbering (FX-time-ig 4, FX-F4-percent 5,
  FX-F5-interval-period 6, FX-F9-bp-members 7, FX-F10-metadata-warnings 8, FX-walking-steadiness 9,
  FX-ecg-frequency-ig 10, FX-route-accuracy 11); rows body-fat-percentage-fraction 5, heart-rate-interval 6,
  blood-pressure-member-metadata 7, walking-steadiness-notification and outlines 9, content-corpus 11; the
  cleanup's retraction rows keep healthKit 1.

Gates on this head: GroveHealthKitFHIR macOS 433 tests (431 passed, 2 skipped, 0 failed); GroveFHIR 108/108;
GroveSensorKitFHIR 97/97; GroveQuestionnaire 272/272; swiftlint --strict clean on GroveFHIRContract,
GroveHealthKitFHIR and its tests; GroveFHIRContract builds at the lowered deployment targets; the generator --check
against the pinned catalog passes; reuse lint compliant (2,924/2,924).

Size (A): code 10,058 (+353), public 676 (-197) against baseline 9b086e6; the round adds 324 code lines and 2
public declarations to 5453c80's 9,734 / 674 (the two HealthKitSampleProjectionError cases, minus the deleted
unsupportedSourceType case, each counted twice by the tool).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 7 milestone M7 (synthesis section 15 M7): measure the rewritten
content layer and fix the documentation that still described removed
internals or the old caller-managed flow. Documentation only; no code,
golden or corpus change.

Documentation fixes:
- GroveDevices HealthKit-Integration.md: the tip said the device this
  module models "appears beside every measurement" for a sample with
  "a published conversion contract" (removed vocabulary). Its hkDevice
  states no localIdentifier, so under the exporter's default
  recording-device policy the graph omits the recording Device and
  warns mobile-omission.recording-device; the tip now says so and names
  RecordingDeviceResolver through the .custom policy.
- GroveHealthKitBulkExport BulkHealthExporter.md: "Deduplicate HealthKit
  samples by participant ID and sample UUID" predates the exporter and
  contradicts the article's own examples; it now separates raw-sample
  processors from graph exporters, which need no UUID (byte-identical
  redelivery before the release, source-record identity after it).
- GroveFHIRContract article: "Five inputs make a context" (old flow);
  the host/instant note told every caller to replay persisted values,
  which only the caller-managed ExchangeEventContext still needs, since
  the producer freezes both per event; the producer list now names the
  Questionnaire exporter too.
- GroveSensorKitFHIR article: the input count named the producer although
  the list includes the exporter's repository scope; the note said the
  exporter freezes the event facts, which the producer does.
- BusinessIdentifier.reference(to:) doc comment: "Grove conversion
  contexts" -> "Grove exchange graphs" (comment only).

DocC: xcodebuild docbuild (iOS Simulator, all traits, catalogs included)
for GroveFHIRContract, GroveQuestionnaireExtraction, GroveSensorKitFHIR,
GroveHealthKitFHIR, GroveDevices and GroveHealthKitBulkExport, before and
after these fixes: all exit 0, and Scripts/build-documentation.py's filter
reports 0 documentation warnings from this repository (no unresolved
symbols) in every one.

Release benchmark (ConversionThroughputBenchmark, xcodebuild Release,
ENABLE_TESTABILITY, N=5000, 3 runs; e4d990b and the M0 tree ddee04a each
built once and run interleaved M0/M7 three times; the machine was shared,
load 5-17, so each number is the best of 3 runs per tree).
observation-content, us per sample: M0 commit / M0 tree today /
M5 commit / M7 today:
  heartRate-minimal                       7.0 /  7.0 / 5.1, 5.4 /  5.0
  heartRate-deployment-watch              8.0 /  8.4 / 5.4, 6.2 /  6.3
  heartRate-deployment-recordingDevice    9.1 /  8.8 / 6.6, 6.9 /  6.9
  stepCount-minimal                      11.0 / 11.5 / 8.8, 8.9 /  8.8
  stepCount-deployment                   11.1 / 11.7 / 8.7, 8.9 /  8.8
  workout-noEvents-deployment            35.4 / 36.5 / 15.9, 15.5 / 14.9
  workout12segments-deployment           39.7 / 38.4 / 15.0, 15.3 / 16.5
No regression against M0 (22-59% faster), equal to M5 within noise.
Whole conversion, best us per sample, M0 tree / M7 (M7 times export()
over the batch including the ledger transaction; M0 timed convert() with
prebuilt contexts): 935.6/903.4, 1342.5/1515.4, 1756.6/1738.4,
939.0/876.9, 1557.2/1228.4, 1546.6/1214.1, 1555.2/1235.5; run-to-run
spread was larger than every difference (heartRate-deployment-watch on M7:
1515, 1915, 2806). Concurrent conversions/s at 1/2/4/6/8 threads, M0 /
M7: 709/839, 1349/1432, 2108/2280, 1941/2495, 2050/2265. One-time plan
compilation (scratch test, not committed): 5.6-6.5 ms on first use in a
fresh Release process, 2.7 ms warm recompile; 218 plans, 0 defects.

Size (measure.py, hand-written code lines / public declarations,
9b086e6 -> 2569b5c -> head):
  GroveFHIRContract            4,576 -> 5,621 -> 6,197 / 579 -> 620 -> 415
  GroveHealthKitFHIR           5,129 -> 4,829 -> 3,861 / 294 -> 384 -> 261
  GroveSensorKitFHIR           4,841 -> 4,841 -> 4,386 / 747 -> 747 -> 724
  GroveQuestionnaireExtraction   996 ->   996 -> 1,086 /  94 ->  94 -> 105
  four modules               15,542 -> 16,287 -> 15,530 / 1,714 -> 1,845 -> 1,505
Top-level public types in the four modules: 153 -> 156 -> 93.

Gates: GroveHealthKitFHIR macOS suite passed (433 tests, 431 passed,
2 skipped benchmarks, 0 failed); swiftlint --strict clean on
GroveFHIRContract, GroveHealthKitFHIR and its tests; reuse lint
compliant; GROVE_LOWERED_DEPLOYMENT_TARGETS=1 swift build --target
GroveFHIRContract succeeds. The only Swift change is one doc-comment line,
so the SensorKit and Questionnaire suites and the generator check were
not rerun. Output unchanged: no Sources code, golden or corpus file
changed.

Size (A): code 10,058 (+353), public 676 (-197)

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

The validator checked only that Observation.device resolves to a Device
entry, so an assembler bug that wired the application or host snapshot
there passed validation, although the docs promise validation fails
closed. The Mobile guide binds Observation.device to the Grove Recording
Device, or to an external logical Device reference (mobile devices.md,
the role table and "Recording device").

The reference context now records which Device entries claim the Grove
Recording Device profile, and a literal Observation.device that resolves
to any other Device is refused as mobile-exchange.reference-target-type.
An identifier-only logical Device reference stays admitted. A new test
rewires an exported heart rate's Observation.device to the converting
application and to its host and expects the refusal, and revalidates the
untouched graph.

Step 12 finding G2. Output unchanged: no producer links Observation.device
to anything but its recording Device; verified by the goldens, the content
corpus and the SensorKit and Questionnaire suites at the series head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A gateway application whose snapshot token equals the converting
application's minted the gateway under the same application role and
token as the converter, so the graph carried two entries under one key
and the validator's distinct-entry-key check refused every
gateway-linked Observation (mobile-exchange.distinct-entry-key).

Such a gateway is the converter itself, so it now resolves as `.gateway`
does: no second snapshot, and observation-gatewayDevice names the
converter's application snapshot. The shared assembler maps it before
minting a gateway snapshot, and the source-neutral SensorConverter
(a removal candidate) skips its separate gateway entry the same way.
A test per path pins the graph byte-identical to the one `.gateway`
emits.

Step 12 finding G13. Output diff: only this configuration changes, from a
refusal of every gateway-linked Observation to the `.gateway` graph; every
other graph is unchanged (goldens and content corpus at the series head).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SensorKitCatalog.sourceToken(for:) mapped 11 sensors and answered nil for
rotation rate, keyboard metrics, messages and phone usage and sleep
sessions, although SensorKitRecord has a case for each and the generated
catalog lists their tokens; entry(for:) therefore missed them too, and a
caller treating nil as "not admitted" (as MHC's batch publication does)
would have refused streams Grove converts.

The function now answers the catalog token for every sensor the catalog
lists: the five above, and the raw-recording streams it also skipped
(face metrics, media events, odometer, Siri and telephony speech
metrics) plus the deferred acoustic settings. The entry the token names
says whether Grove converts the stream, so the doc now points there
instead of promising "supported". Sleep sessions and acoustic settings
are iOS 26 constants and join when the system has them. A new iOS test
resolves every catalogued sensor to a distinct catalog token and pins
the five named ones.

Step 12 finding G6. Output unchanged (no graph reads this lookup); MHC
collects none of the newly resolved sensors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SensorKit generator emitted a public Grove canonical,
SensorKitContract.visitLocationIdentifierSystem
(https://grovealliance.org/fhir/sensorkit/NamingSystem/sensorkit-visit-location-id),
that no NamingSystem or page of the pinned guide defines. The catalog
requires the visit's location system to be "a deployment- or
source-store-scoped absolute Identifier.system"
(catalog/sensorkit-adapter.json), and the SensorKit implementation page
says it is owned by the deployment or exact source-store scope, which is
why SensorKitFHIRExporter already demands a deployment-owned
visitLocationIdentifierSystem. Nothing in Grove or MHC read the constant;
offering it invited a non-compliant system.

The generator no longer emits it; SensorKitGenerated.swift is
regenerated, the generator's --check passes against ig-pin, and its unit
tests pass.

Step 12 finding G7. Output unchanged: no graph used the constant.

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

Five output-neutral clarity fixes from the step-12 maintainer read:

- HealthKitFHIRExporter.request(for:policies:reservation:) returned the
  assembly's HealthKitAssembly.Request but shared its name with
  ExchangeRequestContext.request(for:recordParts:), the ledger request
  that reservation answered. It is now assemblyRequest(for:policies:
  reservation:), and its doc says which request it is not.
- ResolvedPolicies, where an HKDevice resolves to its unit and a source
  to its writer classification, lived in +Fingerprint.swift. It moves to
  its own HealthKitFHIRExporter+ResolvedPolicies.swift, beside the
  SourceFacts that consume it.
- ExchangeOutputDraft.Links.recordingDevice now says that on a
  DocumentReference the author also names the converting application,
  always, as the assembler does.
- Questionnaire spelled the host and application snapshot token formats
  inline, duplicating HostDevice.sourceDeviceToken and
  ApplicationDevice.sourceDeviceToken. Both types now expose one package
  helper over the facts, which their properties and the Questionnaire
  writer snapshots share.
- RecordingDevicePolicy.custom documents the merge rule: name,
  manufacturer and model come from the resolver, each falling back to the
  HKDevice; versions and the UDI always come from the HKDevice.

Step 12 finding G12. Output unchanged: the token helpers spell the same
formats (the Questionnaire baselines and exporter tests, the HealthKit
goldens and content corpus at the series head).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since aadb4c3 kept RetractionEvent in the package, only the HealthKit
exporter could emit a retraction, while the questionnaire guide says a
projected response's outputs "are withdrawn through the retraction path
against their own source-output identifiers" (questionnaire
responses.md, lifecycle and participation metadata), and
ExtractingObservations.md told readers to "retract by them".

QuestionnaireFHIRExporter.retract(_:at:receive:) takes Withdrawal values
(the pair exactly as exported, and when it was withdrawn) and reports one
Retraction per withdrawal, shaped like HealthKit's: one ledger
transaction reserves every retraction event, keyed by the response
identifier with the withdrawal's millisecond as its revision and
fingerprinted with the pair, so an exact retry before release restates
the event and another instant is another event; releasing the receipt
also forgets the response's active reservation. The targets are
recomputed by extracting the pair again: one primary-output target per
Observation the export emitted, under the source-output identity its
graph minted, with the response's source-record identity as the source
entity and the withdrawal instant as Provenance.occurred. A pair the
export refuses is refused alike, before any reservation. The
questionnaire key and the Observation discriminator are now shared by
export and retraction, and the refusal narrowing maps the retraction
builder's errors.

The pair is the input rather than the response identifier alone because
which Observations a response emitted depends on what it answered; a
retraction naming every marked item would name nodes never emitted.

SensorKit keeps no retraction for now: its guide's retraction also names
the device snapshots the record's event emitted (sensorkit
implementation.md, retracting a source record), whose identities are
minted from that prior event, which no input of the exporter states. The
exporter's doc now says it cannot retract yet; choosing that input is
left to the owner.

New tests pin exact target coverage against the exported graph, replay
before release and new events after another instant or a release, the
forgotten active reservation, and refusals reserving nothing.

Step 12 finding G1. Output diff: none for exports; retraction graphs are
new output of the new entry point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The context fingerprint states each exporter's output revisions, so an
exact redelivery stays byte-identical only if every change to the bytes
for equal inputs bumps a revision. For HealthKit that is enforced
(GoldenOutputRevisionTests, 0781a62), but SensorKit had no goldens at
all, although it now assembles through the shared ExchangeGraphAssembler,
and QuestionnaireGraphBaselines.json was tied to no revision, so an
assembler or projection change could alter their bytes unnoticed.

- SensorKit gets a small golden set, one per output shape: a structured
  rotation-rate Observation, the hybrid ECG Observation with its native
  recording, a visit with its location focus, and a raw-only heart-rate
  recording whose payload ships as a sidecar. Each graph's Bundle is
  checked in pretty-printed with sorted members and compared byte for
  byte, and a revision row pins each file's SHA-256 with the assembler
  and SensorKit revisions it last changed under. They regenerate outside
  the checkout through GROVE_SENSORKIT_GOLDEN_OUTPUT_DIR (TEST_RUNNER_
  prefix under xcodebuild), as the HealthKit goldens do; the test target
  now declares its Resources, which REUSE.toml annotates.
- Questionnaire gets one row per baseline case pinning the SHA-256 of the
  exporter's ExchangeGraph.json with the assembler and questionnaire
  revisions, so a change through the baselines or through the approved
  changes fails until its row is updated.

Step 12 finding G11. Output unchanged: the goldens and digests were
generated from the series' code, and the existing Questionnaire baseline
comparison passed on the same run.

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

Documentation fixes for the HealthKit exporter (step 12 findings G3, G4,
G8, and the HealthKit copy of G9's scope claim):

- G3: Deletion.deletedAfter said "the start of the query that first
  reported the deletion", which is too late, as the deletion may precede
  that query. It is now the latest instant every reported deletion is
  known to follow, when the query that produced the starting anchor was
  issued, as GroveHealthKit's HealthKitConstraint.handleDeletedObjects(
  _:ofType:deletedAfter:) defines and passes it; the doc and the landing
  page name that handler and the raw HKAnchoredObjectQuery equivalent.
  Both bounds key the retraction event, so the Deletion doc and the
  "What to persist" table now say to keep them until the retraction's
  receipt is released. (Making Deletion Codable is left for an owner
  decision, as it is new public API.)
- G4: TheExchangeGraph.md promised that the application running the
  exporter, as a writer, folds into the converter's snapshot. It never
  does for an application that states its build: the writer is minted
  from HKSourceRevision.version, which the HealthKit guide requires,
  while the converter states its marketing version and build. The page
  now says the writer is its own snapshot, the guide allows either form,
  and only identical tokens fold (the host when model and OS version
  match; the writer only when the converter states no build and its
  version equals the revision's).
- G8: the "Assemble it" snippet used export(_:), which refuses an ECG
  after reserving its event and refuses heartbeat series and routes. It
  now uses export(records:) and says which kinds need records, that blood
  pressure exports only as the correlation (a member alone is refused as
  componentRequiresCorrelation), how often receive is called (once per
  graph or refusal: an ECG once plus once per symptom, a route under
  .omit never), that exporting again after release mints new events, so
  anchored queries rather than overlapping date windows feed it, and how
  an ECG's symptoms relate to category queries.
- G9: the repository scope enters every source-record and output
  identity, not every opaque identity (Device identities leave it out).

Output unchanged: documentation only.

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

The GroveFHIRContract landing page still taught the caller-managed event
path as an input every reader needs: an "event identifier" section on
numbering events yourself, a persist row for ExchangeEventContext
adapters, and the RepositoryID and ConverterRole paragraphs. It also
claimed the repository scope enters every opaque identity, which the
recording-device and device-snapshot preimages contradict, and its
"re-validate before you trust bytes" read as a mandatory producer step.

The page now leads with the producer, its ledger, the receipt and the
exporter: "The ledger" replaces "The event identifier", and the persist
table keeps the ledger and the key rows. The repository scope enters
every source-record identity and every output identity derived from
one, and the page says which Device identities leave it out.
Re-validation is marked as the reader's step, under the kind the
Bundle's profile claims. ExchangeEventContext, ConverterRole,
ExchangeGraphNode and RepositoryID move into a "Caller-managed
conversion (candidates for removal)" section and topic group, and the
glossary no longer routes the exchange event or the writer through them.

Step 12 finding G9. Output unchanged: documentation only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The GroveQuestionnaireExtraction landing page said only that unmarked
items never project. It did not say that a response answering no marked
item refuses entirely with noExtractableMeasurements, that the writer
context must be stamped when the response is authored (said only in
ExtractingObservations.md), or that a response whose author or source is
not its subject refuses (authorIsNotTheSubject, sourceIsNotTheSubject),
which no page said.

A new "What a response needs to export" section states the three
prerequisites with their refusals, notes that caregiver-authored
responses (which the guide states as the Observations' performer) are not
supported yet, lists the remaining stated-field requirements, and points
to the new retraction entry point.

Step 12 finding G10. Output unchanged: documentation only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SensorKit landing page's anchored-batch example was pseudo-code: it
called undefined helpers (fetchAnchored on an unnamed object,
digestCanonicalSource, verifyRetryOrPersist, makeRecord,
persistedTimeZone) and derived a per-sample ordinal for every stream,
although the tabular and PPG preparations are batch-level. The topics
omitted the preparation types and their retryEvidence, nothing said how
a sidecar travels, and nothing said where sourceTimeZone and the
recording device's stable token come from.

Modeled on MHC's SensorKitBatchPublication and its upload strategies,
the page now:
- says which streams become one record per batch (tabular, PPG,
  rotation rate) and which one per sample (visit, on-wrist, device
  usage, ECG session, wrist-temperature session), and how the source
  record id derives from coordinate, catalog token, product type and
  ordinal;
- names retryEvidence as the bytes to digest, and says an ECG session's
  start, sampling frequency, lead and guidance enter its graph without
  being in its evidence, so they are digested beside it;
- explains the two guards: the app's digest keeps an id meaning one
  content across releases, the exporter's fingerprint keeps an event
  meaning one content before the release;
- says what failing a batch means (throw before acknowledge: same
  coordinate and ids on redelivery, reservations kept) and how a drifted
  batch is abandoned (SensorKit.resetQueryAnchors(for:));
- shows a tabular batch end to end with real API, naming the two
  app-owned pieces it stands for (a digest store and a sidecar upload);
- documents .sidecar(path:): the path is Attachment.url verbatim, with
  size and SHA-1, a relative reference the deployment resolves, so the
  bytes ship unchanged beside the graph;
- says where sourceTimeZone (the device zone at first publication,
  persisted with the batch) and the recordingDevice token (only a
  deployment-governed per-unit token; SensorKit describes models, not
  units) come from, and adds both and the sidecar bytes to the persist
  table;
- states that SensorKit records cannot be retracted yet, and why;
- lists the preparation types in a "Preparing fetched batches" topic.
It also corrects the repository-scope claim as in G9.

Step 12 finding G5. Output unchanged: documentation only.

Gates at this head (the end of the step-12 series): GroveHealthKitFHIR
macOS 433 passed, 2 skipped (benchmark and corpus regeneration); GroveSensorKitFHIR
macOS 100 and iOS 109 passed; GroveQuestionnaire macOS 277 passed;
GroveFHIR macOS 108 passed; SwiftLint strict clean on every touched
directory; GroveFHIRContract builds at the iOS 15 floor; both generators'
--check and reuse lint pass.

Size (A): code 10,090 (+32), public 676 (+0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
31f2973 added the SHA-256 revision rows to
QuestionnaireGraphComparisonTests and imported CryptoKit unconditionally.
GroveQuestionnaireExtractionTests is one of GroveQuestionnaire's
linuxTargets (packages.toml), so the Linux leg's compile-check of the
target fails: Linux has no CryptoKit.

The file now imports CryptoKit where it exists and swift-crypto's Crypto
otherwise, as LedgerFingerprintTests does, and the test target declares
the Crypto product for Linux itself, as GroveQuestionnaireFHIR does,
instead of reaching it only through GroveFHIRContract's edge. The test
uses only SHA256.hash(data:), which both modules provide.

Step 12 verifier finding (Linux CI break from 31f2973). Output
unchanged: tests only, no Sources change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6c772b9 made QuestionnaireFHIRExporter.retract(_:at:receive:) emit
retraction graphs, and 31f2973 tied every Questionnaire export graph to
the output revisions through a SHA-256 row, but no row or golden held the
retraction's bytes, so an assembler or projection change could alter them
without a revision bump and break byte-identical redelivery unnoticed.

QuestionnaireGraphComparisonTests gets a "retraction" row in the same
revision table: the guide's pair withdrawn a day after the guide's
instant, retracted at that instant under the baseline's pinned producer
instance (event sequence 1), whose ExchangeGraph.json SHA-256 is pinned
with the assembler and questionnaire revisions 1. The row test computes
each row's bytes (a case's export graph or the retraction) and the
name-set check admits the retraction beside the cases. The retraction
has no pre-exporter baseline, so it takes part only in the digest check.

Step 12 verifier follow-up. Output unchanged: tests only; the digest was
recorded from the series' code, and the four export rows are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three statements in the exporter docs did not match the code:

- GroveSensorKitFHIR.md ("Publish an anchored batch") told apps to
  abandon a batch whose digests drifted with
  SensorKit.resetQueryAnchors(for:), which resets the committed cursor
  and refetches already-processed samples. The recovery is
  SensorKit.discardPendingBatches(for:) (SensorKit.swift), which keeps
  the acknowledged cursor and fetches the same range again under a new
  reset generation, so fresh coordinates and ids; AnchoredFetcher names
  it as the recovery for pendingBatchMismatch.
- The SensorKit overview said a receiver can retract a graph and "take
  back by identity", while the same page and SensorKitFHIRExporter state
  that SensorKit records cannot be retracted yet. The overview now says
  the receiver can store and compare a graph on a retry, and that
  SensorKit records cannot be taken back yet, linking the section that
  says why.
- GroveHealthKitFHIR.md said an ECG passed to export(_:at:receive:) is
  refused after its event is reserved while a heartbeat series or a
  workout route is refused outright. All three have a content plan, so
  each reserves its event and is then refused by the assembly
  (HealthKitAssembly.convert: ecgEvidence(.evidenceRequired) for the ECG,
  platformExclusiveSourceType for the recording route). The page now
  says so and names both errors, and Record.sample's doc comment, which
  named only the ECG, lists all three.

Step 12 verifier findings (SensorKit recovery API, HealthKit bare-record
refusals, SensorKit overview). Output unchanged: documentation and one
doc comment only.

Size (A): code 10,090 (+385), public 676 (-197); unchanged by this series
(documentation and tests only).

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

Owner decision 2026-10-05 ("Delete now"). Nothing in Grove's exporters or
in MyHeartCounts converts through the caller-managed path, and both halves
already declared themselves removal candidates.

GroveSensorKitFHIR loses the generic SensorConverter family:
SensorConverter, SensorConversionContext, SensorConversion,
SensorGraphIdentifiers, SensorPrimaryResource, SensorConversionWarning,
SensorBatchResult, SensorRecordFailure, SensorConversionError,
SensorRecord, SensorSampledDataRecord, SensorECGRecord, SensorECGChannel,
SensorRecordingDocument, SensorCode and SensorRecordError
(SensorConverter.swift, SensorConverter+Resources.swift,
SensorConversionError.swift, SensorRecords.swift). The one piece the
SensorKit exporter shared, the relative sidecar-path check, moves into
SensorKitNativeRecording as a private helper, unchanged.

GroveFHIRContract loses ExchangeEventContext, ExchangeGraphNode and the
package helpers in ExchangeEventContext+Graph.swift, plus the
Instant(utc:)/DateTime(utc:) initializers only the sensor converter
called (UTCTimestamps.swift becomes TimeZone+UTC.swift, keeping
TimeZone.utc). ConverterRole, still the assembler's input, becomes a
package enum beside ExchangeGraphDraft. RepositoryID stays public:
GroveQuestionnaireFHIR's builders take it.

RetractionEvent no longer takes an ExchangeEventContext. It builds from
what a retraction graph states: the reserved event and its instant, the
envelope's identity scope, the converting application the event's
frozen facts name, and the optional legacy Bundle.id. HealthKitAssembly
passes its ExchangeEnvelope scope and request facts (eventContext(for:)
is gone); the Questionnaire exporter passes its producer's identity
scope and the reservation's facts. Validation order and every emitted
byte are unchanged. It takes the identity scope and application rather
than a whole ExchangeEnvelope because a retraction states no adapter
profile, subject or repository scope, and the Questionnaire exporter
has no adapter contract to build an envelope from.

Tests: SensorConverterTests and SensorContextValidationTests exercised
only the deleted API and are removed; ProducerDefaultsTests drops its
ExchangeEventContext-defaults case. Behaviour the exporters still have
stays covered through them: gateway applications on Observations versus
documents, the converter-as-gateway rule and repository ids
(HealthKit exporter tests), UTC clock instants, DST-edge bounds and raw
payload admission (SensorKit exporter tests). Two cases move over: the
identity-fault registry codes now pin SensorKitConversionError alone,
and a new RegisteredRecordingPayloadTests case pins the sidecar-path
refusal through SensorKitNativeRecording, which had no test of its own.
The HealthKit tests' fixture becomes a test-only TestEvent with the same
values, and their retraction cases build through it.

DocC: GroveFHIRContract.md drops the caller-managed section and topic
group and lists RepositoryID under the graph topics with the one line
that says what takes it; GroveSensorKitFHIR.md drops the source-neutral
producer section and topic group. No article names a deleted symbol.

Output unchanged: all HealthKit goldens, outlines.json, the content
corpus, the SensorKit goldens and the Questionnaire graph digests pass
unregenerated in the GroveHealthKitFHIR, GroveSensorKitFHIR and
GroveQuestionnaire suites at this head.

Size (A): code 9,997 (+292), public 637 (-236); against 879651a: code
-93, public -39. Level B: code 18,561 (-1,206), public 1,357 (-419);
against 879651a: code -1,153, public -199.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every HealthKit export keeps its per-sample warnings in Export.warnings,
unchanged. A historical export raises the same few warnings for almost
every sample (a device without localIdentifier, no HKMetadataKeyTimeZone),
so logging each one floods the log. HealthKitFHIRExporter.WarningReport
is the second mechanism: add every export of a batch, then log its
description once. It names each kind of warning with its HealthKit cause
and counts the samples of each type that raised it.

The report counts samples, not located elements: a step count without a
time zone raises the source offset at both ends of its Period and counts
once. It keeps no sample identity, so its memory does not grow with the
batch. The three codes GroveHealthKitFHIR raises
(mobile-omission.source-offset, .recording-device, .unmodeled-metadata;
all from HealthKitAssembly) each get a HealthKit cause sentence; any
other code falls back to its IG reason. Refusals carry no warnings
today (the exporter always passes none), so they add nothing; add()
reads the warnings of every outcome alike. Kinds render the most samples
first, then by code; types the most first, then by identifier; counts
with grouped thousands in a fixed locale, so the text is deterministic.

The DocC article and the Export.warnings comment point to the report.

Tests: HealthKitFHIRExporterWarningReportTests exports, through a real
exporter, two heart rates without a time zone (one on a device without
a per-unit token), a step count over a minute without a time zone
carrying an unmodeled key, a zoned heart rate with that key, a zoned
heart rate on a device without a token, a clean heart rate and a bare
ECG refusal, and pins the kinds, per-type sample counts, locations,
ordering, the exact description (also for the reversed order), the
empty report, grouped thousands and the unknown-code fallback.

Output unchanged: no Sources change touches conversion; goldens and the
content corpus pass unchanged in the GroveHealthKitFHIR suite.

Size (A): code 10,069 (+364), public 650 (-223); against 7965f5a: code
+72, public +13 (the report type, its Kind and their members). Level B:
code 18,633 (-1,134), public 1,370 (-406).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lukaskollmer lukaskollmer self-assigned this Oct 6, 2026
@lukaskollmer lukaskollmer added the enhancement New feature or request label Oct 6, 2026
@lukaskollmer
lukaskollmer added this pull request to stack #112 October 6, 2026 13:19
@coderabbitai

coderabbitai Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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

Labels

enhancement New feature or request

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

1 participant