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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions Sources/GroveFHIRContract/ExchangeGraph.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,19 @@ public struct ExchangeGraph: Sendable {

/// Re-validates stored or received JSON before it is trusted again.
///
/// Serialized checks run first, because decoding through `Foundation.URL` could otherwise
/// normalize an identity system or collapse a prohibited resource type before the model sees it.
/// Serialized checks run first, because Foundation keeps only one of duplicate members and
/// decoding through `Foundation.URL` could normalize an identity system or collapse a
/// prohibited resource type before the model sees it.
public init(
kind: ExchangeGraphKind,
jsonData: Data
) throws(ExchangeGraphError) {
do {
var scanner = StrictJSONScanner(jsonData)
try scanner.validate()
} catch {
throw .invalidEntries("Serialized event is not strict JSON")
}
try Self.validateSerializedEntryPolicy(kind: kind, data: jsonData)
do {
try ExchangeIdentity.validateSerializedIdentifierSystems(in: jsonData)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ A retry is exact when ``ExchangeGraph/isSemanticallyEqual(to:)`` says so: member

A ``RetractionEvent`` takes back earlier outputs by typed identity; each ``RetractionTarget`` names the identity, the resource type and its ``RetractionTargetRole``.
A target carries the record's ``RetractionTarget/nativeRecordIdentifier`` only where the governed-source-identifier policy authorizes it, and the event renders it beside the target without ever addressing the target by it.
Its ``RetractionOccurrence`` is the deletion or detection instant, or bounds on the deletion when the source states no time.

Every refusal and every warning is one ``ProducerDiagnostic`` whose code is a registered ``ExchangeGraphRule``.
The conformance lane in `Scripts/validate-fhir-conformance.sh` proves an adapter's output against the grove-fhir corpora and the official validator.
Expand Down Expand Up @@ -203,6 +204,7 @@ The conformance lane in `Scripts/validate-fhir-conformance.sh` proves an adapter
- ``RetractionEvent``
- ``RetractionTarget``
- ``RetractionTargetRole``
- ``RetractionOccurrence``

### Disclosure

Expand Down
41 changes: 35 additions & 6 deletions Sources/GroveFHIRContract/RetractionEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,19 +104,47 @@ public enum RetractionTargetError: Error, Equatable, Sendable {
}


/// When the source retracted a record, as precisely as the producer knows it.
public enum RetractionOccurrence: Hashable, Sendable {
/// The source's own deletion time, or the time the producer detected the deletion.
case instant(Date)
/// Bounds on a deletion time the source does not state: after `start` when known, and no later than `end`.
case period(start: Date?, end: Date)

fileprivate func occurredX() throws(RetractionEventError) -> Provenance.OccurredX {
if case let .period(start?, end) = self, start > end {
throw .invalidOccurrencePeriod
}
do {
switch self {
case .instant(let date):
return .dateTime(FHIRPrimitive(try DateTime(utc: date)))
case let .period(start, end):
return .period(Period(
end: FHIRPrimitive(try DateTime(utc: end)),
start: try start.map { FHIRPrimitive(try DateTime(utc: $0)) }
))
}
} catch {
throw .invalidInstant
}
}
}


/// A validated lifecycle assertion that names prior graph nodes without copying them.
///
/// The converting application is the assembler, referenced logically through its event-scoped
/// snapshot identity; the retraction occurred when the source deleted the record and was recorded
/// at the context's conversion instant.
/// snapshot identity; `occurred` states the source's deletion time or the bounds the producer
/// knows, and the assertion is recorded at the context's conversion instant.
public struct RetractionEvent: Sendable {
public let graph: ExchangeGraph

public init(
targets: [RetractionTarget],
context: ExchangeEventContext,
sourceRecord: RoledIdentifier,
retractedAt: Date
occurred: RetractionOccurrence
) throws(RetractionEventError) {
guard !targets.isEmpty else {
throw .emptyTargets
Expand All @@ -141,10 +169,9 @@ public struct RetractionEvent: Sendable {
} catch {
throw .opaqueIdentity(error)
}
let occurred: DateTime
let occurredX = try occurred.occurredX()
let recorded: Instant
do {
occurred = try DateTime(utc: retractedAt)
recorded = try Instant(utc: context.conversionInstant)
} catch {
throw .invalidInstant
Expand All @@ -171,7 +198,7 @@ public struct RetractionEvent: Sendable {
what: Reference(identifier: sourceRecord.fhirIdentifier)
)],
meta: Meta(profile: [GroveLifecycleContract.retractionProvenanceProfile]),
occurred: .dateTime(FHIRPrimitive(occurred)),
occurred: occurredX,
recorded: FHIRPrimitive(recorded),
target: targets.map(\.reference)
)
Expand Down Expand Up @@ -212,6 +239,8 @@ public enum RetractionEventError: Error, Equatable, Sendable {
/// A native record identifier reuses one of the deployment's Grove identity systems.
case reservedIdentifierSystem
case invalidInstant
/// A retraction period starts after it ends.
case invalidOccurrencePeriod
case opaqueIdentity(OpaqueIdentityError)
case exchangeIdentity(ExchangeIdentityError)
case exchangeGraph(ExchangeGraphError)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,15 @@ extension HKHealthStore {
/// `@unchecked Sendable` safety: all strong fields are immutable, `query` is assigned once and
/// only weak-zeroed by the Swift runtime, and `HKHealthStore` supports cross-thread query stop.
final class BackgroundObserverQueryInvalidator: @unchecked Sendable {
let objectTypes: Set<HKObjectType>
private let healthStore: HKHealthStore
private weak var query: HKQuery?
private let taskTracker: BackgroundDeliveryTaskTracker

init(healthStore: HKHealthStore, query: HKQuery, taskTracker: BackgroundDeliveryTaskTracker) {
init(healthStore: HKHealthStore, query: HKQuery, objectTypes: Set<HKObjectType>, taskTracker: BackgroundDeliveryTaskTracker) {
self.healthStore = healthStore
self.query = query
self.objectTypes = objectTypes
self.taskTracker = taskTracker
}

Expand Down Expand Up @@ -209,6 +211,23 @@ extension HKHealthStore {
Result<Set<HKSampleType>, any Error>
) async -> Void
) async throws -> BackgroundObserverQueryInvalidator {
let observation = installBackgroundObserver(for: sampleTypes, withPredicate: predicate, updateHandler: updateHandler)
do {
try await enableBackgroundDelivery(for: observation.objectTypes)
} catch {
await observation.invalidateAndWait()
throw error
}
return observation
}

/// Installs synchronously so launch callers do not depend on an asynchronous task being scheduled.
@MainActor
func installBackgroundObserver(
for sampleTypes: Set<HKSampleType>,
withPredicate predicate: NSPredicate? = nil,
updateHandler: @escaping @MainActor @Sendable (Result<Set<HKSampleType>, any Error>) async -> Void
) -> BackgroundObserverQueryInvalidator {
let taskTracker = BackgroundDeliveryTaskTracker()
let queryDescriptors: [HKQueryDescriptor] = sampleTypes
.flatMap { $0.effectiveObjectTypesForAuthorization }
Expand Down Expand Up @@ -243,16 +262,7 @@ extension HKHealthStore {
}
}
self.execute(observerQuery)
do {
try await enableBackgroundDelivery(for: queryDescriptors.mapIntoSet(\.sampleType))
} catch {
// `execute` starts delivering immediately. If registration fails, there is no
// invalidator to hand back to the caller, so tear down both halves here.
self.stop(observerQuery)
await taskTracker.cancelAndWait()
throw error
}
return .init(healthStore: self, query: observerQuery, taskTracker: taskTracker)
return .init(healthStore: self, query: observerQuery, objectTypes: queryDescriptors.mapIntoSet(\.sampleType), taskTracker: taskTracker)
}


Expand All @@ -277,12 +287,16 @@ extension HKHealthStore {
func disableBackgroundDelivery(
for objectTypes: Set<HKObjectType>
) async {
let objectTypesToDisable = Self.backgroundDeliveryOwnership.withLock {
$0.requestDisable(for: objectTypes)
}
for objectType in objectTypesToDisable {
await disablePendingBackgroundDelivery(for: objectType)
}
// Teardown must finish even when its owner is cancelled, including rollback after
// partial registration. Await the independent task so retries cannot outlive cleanup.
await Task { @MainActor in
let objectTypesToDisable = Self.backgroundDeliveryOwnership.withLock {
$0.requestDisable(for: objectTypes)
}
for objectType in objectTypesToDisable {
await disablePendingBackgroundDelivery(for: objectType)
}
}.value
}

@MainActor
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
//
// This source file is part of the Grove open-source project
//
// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

#if canImport(HealthKit)

public import HealthKit


@available(iOS 18, macOS 15, watchOS 11, *)
extension HKHealthStore {
/// Observes changes for the duration of an asynchronous operation without owning query anchors.
///
/// - Important: Experimental SPI. Requires `@_spi(Experimental) import GroveHealthKit`;
/// this interface may change or be removed without source-compatibility guarantees.
///
/// Use this when an application already owns durable query checkpoints. The update handler receives
/// wake signals, not samples. Query and durably stage the corresponding changes before returning;
/// Grove acknowledges each callback after the handler returns, including cancellation and error paths.
/// Handlers can overlap and must coordinate access to application-owned checkpoints themselves.
///
/// The operation keeps the observation alive. On success, failure or cancellation, this method stops
/// the query, cancels and awaits its handlers, then releases background-delivery ownership. SDK disable
/// failures use Grove's existing bounded retry and logging. Other Grove collectors retain their ownership.
/// Both the operation and update handler must cooperate with cancellation; this is not a hard deadline.
///
/// This method does not request HealthKit permission or establish study consent. The caller must
/// install its observation during application launch and provide the background-delivery entitlement.
/// Use `startBackgroundObservation` from a synchronous launch hook; starting this async scope
/// in a view task does not guarantee registration before the launch hook returns.
/// Test operating-system background wakes on a physical device, not only in Simulator.
/// See Apple's [observer setup](https://developer.apple.com/documentation/healthkit/executing-observer-queries)
/// and [acknowledgement contract](https://developer.apple.com/documentation/healthkit/hkobserverquerycompletionhandler).
///
/// - Parameters:
/// - sampleTypes: Types to observe. An empty set runs the operation without registering a query.
/// - updateHandler: A bounded processing attempt for each wake or observer error. A failed attempt
/// must leave the application's checkpoint retryable; acknowledgement is not a custody receipt.
/// - operation: Work defining the observation's lifetime, usually awaiting application-owned cancellation.
/// - Returns: The operation's result, after observation cleanup.
/// - Throws: Registration or operation errors, or `CancellationError` when the caller is cancelled.
@_spi(Experimental)
@MainActor
public func withBackgroundObservation<Result: Sendable>(
for sampleTypes: Set<HKSampleType>,
updateHandler: @escaping @MainActor @Sendable (Swift.Result<Set<HKSampleType>, any Error>) async -> Void,
operation: @escaping @MainActor @Sendable () async throws -> Result
) async throws -> Result {
try Task.checkCancellation()
let task = startBackgroundObservation(for: sampleTypes, updateHandler: updateHandler, operation: operation)
return try await withTaskCancellationHandler {
try await task.value
} onCancel: {
task.cancel()
}
}

/// Installs the observer before returning, then owns asynchronous delivery registration and cleanup.
///
/// - Important: Experimental SPI. Requires `@_spi(Experimental) import GroveHealthKit`;
/// this interface may change or be removed without source-compatibility guarantees.
///
/// Call from application launch, retain the returned task, and cancel and await it when the
/// observation is no longer needed. Parent cancellation is not inherited by this unstructured
/// task; `withBackgroundObservation` provides that forwarding for asynchronous callers.
/// An already-cancelled caller installs nothing. Registration failures and operation completion
/// stop the query and await all handlers before releasing background-delivery ownership.
///
/// This adds no query checkpoint, permission prompt, retry scheduler or participant authority.
/// The same entitlement, bounded-handler and physical-device requirements as the async scope apply.
@_spi(Experimental)
@MainActor
public func startBackgroundObservation<Result: Sendable>(
for sampleTypes: Set<HKSampleType>,
updateHandler: @escaping @MainActor @Sendable (Swift.Result<Set<HKSampleType>, any Error>) async -> Void,
operation: @escaping @MainActor @Sendable () async throws -> Result
) -> Task<Result, any Error> {
guard !Task.isCancelled else {
return Task { throw CancellationError() }
}
let observation = sampleTypes.isEmpty ? nil : installBackgroundObserver(for: sampleTypes, updateHandler: updateHandler)
return Task { @MainActor in
try await withInstalledObservation(observation, operation: operation)
}
}

@MainActor
private func withInstalledObservation<Result>(
_ observation: BackgroundObserverQueryInvalidator?,
operation: @MainActor () async throws -> Result
) async throws -> Result {
var enabled = false
let result: Result
do {
try Task.checkCancellation()
if let observation {
try await enableBackgroundDelivery(for: observation.objectTypes)
enabled = true
}
try Task.checkCancellation()
result = try await operation()
} catch {
await observation?.invalidateAndWait()
if enabled, let observation { await disableBackgroundDelivery(for: observation.objectTypes) }
Comment thread
PSchmiedmayer marked this conversation as resolved.
throw error
}
await observation?.invalidateAndWait()
if enabled, let observation { await disableBackgroundDelivery(for: observation.objectTypes) }
try Task.checkCancellation()
return result
}
}

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,16 @@ Never send an email address, display label, bare value, or literal URL in place

A known enrollment travels as a `StudyEnrollment`: the study identifier, the protocol's canonical URL and version, and the enrollment identifier.
The converter emits the `ResearchStudy`, `PlanDefinition`, and `ResearchSubject` entries itself under the catalog's entry-node roles, and every output carries the `workflow-researchStudy` extension.
A recording document names its studies in `DocumentReference.context.related` instead.
`studies` defaults to none.

Each enrollment keeps its own protocol revision: study A can carry protocol A version 2 while study B carries protocol B version 4.
Study relevance does not assert that a measurement followed a protocol, so no output carries `instantiatesCanonical`.
It grants no access either; consent and access decisions stay with the receiver, and a study-scoped export omits unrelated study associations.

Persist `studies` with the rest of the event context and reuse them unchanged for a retry.
A later study association is the receiver's decision, recorded separately, not a reconversion with altered metadata.

## Identifying the converting application

`ApplicationDevice` records which app produced the graph; `HostDevice` is the separate device on which it ran.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,10 @@ let batch = HealthKitConverter().convert(samples) { sample in
```

A retry is exact when `ExchangeGraph.isSemanticallyEqual(to:)` says so.
A deleted sample is taken back with ``HealthKitConverter/retraction(for:context:retractedAt:)``.
A deleted sample is taken back with ``HealthKitConverter/retraction(for:context:occurred:)``.
It needs only the deleted object's UUID and the sample type it was reported for; the source record and every output it retracts are recomputed, so nothing from the sample's conversion has to be kept.
`retractionContext` is a ``HealthKitConversionContext`` for the retraction's own new event, under the same identity scope, repository scope and native-identifier disclosure as the conversion.
HealthKit reports a deletion without its time, so `occurred` bounds it by the `deletedAfter` the deletion handler received and the time it was reported.

```swift
guard let type = HealthKitSourceType(sampleType.hkSampleType) else {
Expand All @@ -164,7 +165,7 @@ guard let type = HealthKitSourceType(sampleType.hkSampleType) else {
let retraction = try HealthKitConverter().retraction(
for: HealthKitSourceRecord(uuid: deletedObject.uuid, type: type),
context: retractionContext,
retractedAt: deletedAt
occurred: .period(start: deletedAfter, end: reportedAt)
)
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

#if canImport(HealthKit)

public import Foundation
import Foundation
public import GroveFHIRContract
public import HealthKit

Expand Down Expand Up @@ -44,7 +44,7 @@ extension HealthKitConverter {
public func retraction(
for record: HealthKitSourceRecord,
context: HealthKitConversionContext,
retractedAt: Date
occurred: RetractionOccurrence
) throws(HealthKitConversionError) -> RetractionEvent {
let targets = try retractionTargets(for: record, context: context)
let sourceRecord = try Self.sourceRecord(for: record, context: context)
Expand All @@ -53,7 +53,7 @@ extension HealthKitConverter {
targets: targets,
context: context.event,
sourceRecord: sourceRecord.identifier,
retractedAt: retractedAt
occurred: occurred
)
} catch {
throw HealthKitConversionError(error)
Expand Down Expand Up @@ -118,7 +118,7 @@ extension HealthKitConversionError {
case .opaqueIdentity(let error): .opaqueIdentity(error)
case .exchangeIdentity(let error): .exchangeIdentity(error)
case .exchangeGraph(let error): .exchangeGraph(error)
case .emptyTargets, .duplicateTarget, .invalidSourceRecord, .invalidInstant:
case .emptyTargets, .duplicateTarget, .invalidSourceRecord, .invalidInstant, .invalidOccurrencePeriod:
.dependency(HealthKitDependencyFailure(underlying: error))
}
}
Expand Down
Loading
Loading