sos-sdk-core-ts 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README.md +32 -13
- package/dist/care-emergency-alert-orchestration.d.ts +131 -0
- package/dist/care-emergency-alert-orchestration.js +271 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/docs/CARE_EMERGENCY_AND_ALERT_ORCHESTRATION.md +107 -0
- package/package.json +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- Require the initial emergency triage Task owner to be a professional Group,
|
|
6
|
+
while keeping the accepting PractitionerRole as Task owner and actual
|
|
7
|
+
callback/message sender after acknowledgement.
|
|
8
|
+
- Clarify that Group reply routing never replaces the legal Organization
|
|
9
|
+
document author, professional attester or authenticated transport actor.
|
|
10
|
+
|
|
11
|
+
## 0.3.1 - 2026-09-27
|
|
12
|
+
|
|
13
|
+
- Pin the latest already-published `sos-data-utils-ts@0.4.2` baseline while
|
|
14
|
+
grouped unreleased Task lifecycle changes continue through immutable local
|
|
15
|
+
tarballs.
|
|
16
|
+
- Add high-level emergency triage Task readers and deterministic
|
|
17
|
+
acknowledge/start/escalate/reject/complete transitions without rewriting the
|
|
18
|
+
originating ServiceRequest.
|
|
19
|
+
- Preserve the protected communication-thread correlation on the Task and
|
|
20
|
+
authorize responder callbacks only for the accepted or in-progress Task
|
|
21
|
+
owner; endpoint resolution and signaling remain product-server concerns.
|
|
22
|
+
|
|
23
|
+
## 0.3.0 - 2026-09-26
|
|
24
|
+
|
|
25
|
+
- Pin `sos-data-utils-ts@0.4.1`, which owns the Organization fallback target
|
|
26
|
+
and governed public-risk campaign criteria used by this release.
|
|
27
|
+
- Add idempotent fulfilled-Appointment to Encounter orchestration with
|
|
28
|
+
Appointment, subject, service provider and current Location continuity.
|
|
29
|
+
- Add fail-closed authorization for encounter-related voice, video and secure
|
|
30
|
+
messages. Calls never carry clinical Bundles, and preliminary Bundles may be
|
|
31
|
+
delivered only without updating the subject index.
|
|
32
|
+
- Create independent compact-coded emergency ServiceRequest and triage Task
|
|
33
|
+
entries targeting a HealthcareService or its owning Organization.
|
|
34
|
+
- Add frozen-cohort CommunicationRequest orchestration and per-recipient
|
|
35
|
+
delivery checks for blood-donation and governed FHIR SearchParameter
|
|
36
|
+
public-risk campaigns.
|
|
37
|
+
- Replace copied emergency Group members and split coding claims with the
|
|
38
|
+
canonical Group-reference and compact-code contract.
|
|
39
|
+
- Document the immutable main-tarball local Vet proof required before npm
|
|
40
|
+
publication and staging.
|
|
41
|
+
|
|
5
42
|
## 0.2.2 - 2026-09-25
|
|
6
43
|
|
|
7
44
|
- Add runtime-neutral ML-KEM-768 multi-recipient encryption for written and
|
package/README.md
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# Shared SOS SDK Core
|
|
2
2
|
## Blocking local-first SDK release
|
|
3
3
|
|
|
4
|
-
The canonical contract is [`SDK_LAYERING.md`](https://github.com/Fundacion-UNID/sos-data-utils-ts/blob/main/docs/SDK_LAYERING.md).
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
The canonical contract is [`SDK_LAYERING.md`](https://github.com/Fundacion-UNID/sos-data-utils-ts/blob/main/docs/SDK_LAYERING.md).
|
|
5
|
+
Merge and push validated shared dependency sources to `main`, create immutable
|
|
6
|
+
unpublished tarballs from those main commits with `npm pack`, and install them
|
|
7
|
+
temporarily with `--no-save`. Prove the real local Vet channel (portal, phone
|
|
8
|
+
service, chatbot or other supported channel) -> BFF or channel service ->
|
|
9
|
+
high-level SDK -> GW VET journey without skips. Then merge the consumer source
|
|
10
|
+
to `main`, publish the exact commits bottom-up, pin exact registry versions and
|
|
11
|
+
lockfiles, repeat affected local gates, and only then promote to staging.
|
|
12
|
+
Never commit a tarball or `file:`, Git or workspace dependency.
|
|
10
13
|
|
|
11
14
|
|
|
12
15
|
Runtime-neutral shared SDK for SOSChain, VetChain and UHC. Deterministic
|
|
@@ -35,13 +38,11 @@ jobs; those adapters do not belong here.
|
|
|
35
38
|
Git, workspace, vendored and `file:` dependencies are forbidden in committed
|
|
36
39
|
or deployed dependency state.
|
|
37
40
|
|
|
38
|
-
The emergency-group contract
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
Groups are named collections. A scanned card is attached to one selected group
|
|
44
|
-
before that group's identifiers are projected into a single service request.
|
|
41
|
+
The emergency-group contract stores subject references only on an enumerated
|
|
42
|
+
FHIR Group. A group assistance request references that Group from one standard,
|
|
43
|
+
compact-coded ServiceRequest; it never copies members or persists split coding
|
|
44
|
+
claims. Flat claims remain the canonical storage representation and native
|
|
45
|
+
FHIR is projected only at an explicit boundary.
|
|
45
46
|
|
|
46
47
|
`authorizeEmergencyAction` separately checks trusted evidence for every
|
|
47
48
|
member and for `sos:call` or `sos:request`. Identity verification alone never
|
|
@@ -57,6 +58,24 @@ to the same types and implementation; they are not a second contract.
|
|
|
57
58
|
`buildSoschainPlaceCardAssociationClaims` records a UHC/VetChain card link to
|
|
58
59
|
a canonical SOSChain Place or vehicle without merging either identity.
|
|
59
60
|
|
|
61
|
+
## Care, emergency and alert orchestration
|
|
62
|
+
|
|
63
|
+
`sos-sdk-core-ts/care-emergency-alert-orchestration` supplies the shared,
|
|
64
|
+
runtime-neutral plans for Appointment-to-Encounter continuity, protected
|
|
65
|
+
pre-publication calls/messages, resilient emergency ServiceRequest plus triage
|
|
66
|
+
Task creation/transition/responder callback authorization, and governed
|
|
67
|
+
donation/public-risk campaigns. Transport,
|
|
68
|
+
persistence, authorization evidence acquisition and product policy remain in
|
|
69
|
+
the channel service, Node adapter and Vet/UHC/SOSChain layers.
|
|
70
|
+
|
|
71
|
+
Emergency triage starts with a professional Group owner and changes to the
|
|
72
|
+
accepting PractitionerRole. The Group remains a reply queue, never the
|
|
73
|
+
clinical-document author or human Communication sender; those identities stay
|
|
74
|
+
separate from the professional attester and transport audit evidence.
|
|
75
|
+
|
|
76
|
+
See [docs/CARE_EMERGENCY_AND_ALERT_ORCHESTRATION.md](docs/CARE_EMERGENCY_AND_ALERT_ORCHESTRATION.md)
|
|
77
|
+
for lifecycle, authorization and persistence invariants.
|
|
78
|
+
|
|
60
79
|
## Multi-recipient secure messages
|
|
61
80
|
|
|
62
81
|
`sos-sdk-core-ts/secure-message-crypto` protects written and binary-audio
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { type CareJourneyTransition, type EmergencyIntake, type GovernedAlertCampaign, type GovernedAlertCohortSnapshot, type GovernedAlertDeliveryAudit } from 'sos-data-utils-ts/care-emergency-alert-records';
|
|
2
|
+
import { type ClinicalCoding } from 'fhir-data-utils-ts/care-emergency-alert';
|
|
3
|
+
import type { FlatClaimResourceEntry } from 'fhir-data-utils-ts/flat-claim-resource-graph';
|
|
4
|
+
export type FulfilledAppointmentEncounterPlan = Readonly<{
|
|
5
|
+
created: false;
|
|
6
|
+
encounterReference: string;
|
|
7
|
+
}> | Readonly<{
|
|
8
|
+
created: true;
|
|
9
|
+
encounterReference: string;
|
|
10
|
+
encounterEntry: FlatClaimResourceEntry;
|
|
11
|
+
}>;
|
|
12
|
+
/**
|
|
13
|
+
* Plans one idempotent Appointment-to-Encounter transition. Persistence must
|
|
14
|
+
* enforce `idempotencyKey`; an existing reference suppresses duplicate writes.
|
|
15
|
+
*/
|
|
16
|
+
export declare function orchestrateFulfilledAppointment(input: Readonly<{
|
|
17
|
+
transition: CareJourneyTransition;
|
|
18
|
+
encounterIdentifier: string;
|
|
19
|
+
existingEncounterReference?: string;
|
|
20
|
+
}>): FulfilledAppointmentEncounterPlan;
|
|
21
|
+
export type EncounterCommunicationAuthorization = Readonly<{
|
|
22
|
+
encounterReference: string;
|
|
23
|
+
actorReference: string;
|
|
24
|
+
recipientReferences: readonly string[];
|
|
25
|
+
channel: 'voice' | 'video' | 'encrypted-message';
|
|
26
|
+
includesClinicalBundle: boolean;
|
|
27
|
+
clinicalStatus?: 'preliminary' | 'final';
|
|
28
|
+
updateIndex: boolean;
|
|
29
|
+
}>;
|
|
30
|
+
/**
|
|
31
|
+
* Authorizes only the channel boundary. It never attests clinical data.
|
|
32
|
+
* Preliminary encrypted Bundles may be delivered directly but cannot update
|
|
33
|
+
* the subject index; voice/video signaling cannot carry a clinical Bundle.
|
|
34
|
+
*/
|
|
35
|
+
export declare function authorizeEncounterCommunication(input: Readonly<{
|
|
36
|
+
encounterReference: string;
|
|
37
|
+
actorReference: string;
|
|
38
|
+
recipientReferences: readonly string[];
|
|
39
|
+
actorCanCommunicate: boolean;
|
|
40
|
+
encounterActive: boolean;
|
|
41
|
+
channel: EncounterCommunicationAuthorization['channel'];
|
|
42
|
+
includesClinicalBundle: boolean;
|
|
43
|
+
clinicalStatus?: 'preliminary' | 'final';
|
|
44
|
+
updateIndex: boolean;
|
|
45
|
+
}>): EncounterCommunicationAuthorization;
|
|
46
|
+
/** Creates independent standard ServiceRequest and durable triage Task entries. */
|
|
47
|
+
export declare function orchestrateEmergencyRequest(input: Readonly<{
|
|
48
|
+
intake: EmergencyIntake;
|
|
49
|
+
serviceRequestIdentifier: string;
|
|
50
|
+
triageTaskIdentifier: string;
|
|
51
|
+
assistanceCode: ClinicalCoding;
|
|
52
|
+
}>): Readonly<{
|
|
53
|
+
intake: EmergencyIntake;
|
|
54
|
+
serviceRequestEntry: FlatClaimResourceEntry;
|
|
55
|
+
triageTaskEntry: FlatClaimResourceEntry;
|
|
56
|
+
}>;
|
|
57
|
+
export type EmergencyTriageAction = 'acknowledge' | 'start' | 'escalate' | 'reject' | 'complete';
|
|
58
|
+
export type EmergencyTriageTask = Readonly<{
|
|
59
|
+
taskReference: string;
|
|
60
|
+
status: string;
|
|
61
|
+
focusReference: string;
|
|
62
|
+
subjectReference: string;
|
|
63
|
+
requesterReference: string;
|
|
64
|
+
ownerReference: string;
|
|
65
|
+
authoredAt: string;
|
|
66
|
+
communicationThreadId?: string;
|
|
67
|
+
lastModified?: string;
|
|
68
|
+
}>;
|
|
69
|
+
/**
|
|
70
|
+
* Reads only the standard, claims-first coordinates required by a responder
|
|
71
|
+
* console. It does not resolve contact endpoints or grant callback authority.
|
|
72
|
+
*/
|
|
73
|
+
export declare function readEmergencyTriageTask(taskEntry: FlatClaimResourceEntry): EmergencyTriageTask;
|
|
74
|
+
/**
|
|
75
|
+
* Applies one deterministic responder transition to the Task only. The
|
|
76
|
+
* ServiceRequest remains immutable; callers must authorize the responder and
|
|
77
|
+
* resolve any escalation owner before invoking this function.
|
|
78
|
+
*/
|
|
79
|
+
export declare function transitionEmergencyTriageTask(input: Readonly<{
|
|
80
|
+
taskEntry: FlatClaimResourceEntry;
|
|
81
|
+
action: EmergencyTriageAction;
|
|
82
|
+
responderReference: string;
|
|
83
|
+
escalationOwnerReference?: string;
|
|
84
|
+
occurredAt: string;
|
|
85
|
+
}>): FlatClaimResourceEntry;
|
|
86
|
+
export type EmergencyResponderCallback = Readonly<{
|
|
87
|
+
taskReference: string;
|
|
88
|
+
serviceRequestReference: string;
|
|
89
|
+
subjectReference: string;
|
|
90
|
+
requesterReference: string;
|
|
91
|
+
responderReference: string;
|
|
92
|
+
communicationThreadId: string;
|
|
93
|
+
media: 'audio' | 'video';
|
|
94
|
+
}>;
|
|
95
|
+
/**
|
|
96
|
+
* Authorizes callback correlation for the current Task owner. Protected
|
|
97
|
+
* endpoint resolution and WebRTC signaling remain in the product server.
|
|
98
|
+
*/
|
|
99
|
+
export declare function prepareEmergencyResponderCallback(input: Readonly<{
|
|
100
|
+
taskEntry: FlatClaimResourceEntry;
|
|
101
|
+
responderReference: string;
|
|
102
|
+
media: 'audio' | 'video';
|
|
103
|
+
}>): EmergencyResponderCallback;
|
|
104
|
+
export type GovernedAlertCampaignPlan = Readonly<{
|
|
105
|
+
campaign: GovernedAlertCampaign;
|
|
106
|
+
cohortSnapshot: GovernedAlertCohortSnapshot;
|
|
107
|
+
communicationRequestEntry: FlatClaimResourceEntry;
|
|
108
|
+
}>;
|
|
109
|
+
/** Builds one campaign request bound to the frozen evaluated Group snapshot. */
|
|
110
|
+
export declare function prepareGovernedAlertCampaign(input: Readonly<{
|
|
111
|
+
campaign: GovernedAlertCampaign;
|
|
112
|
+
snapshotGroupReference: string;
|
|
113
|
+
evaluatedAt: string;
|
|
114
|
+
evaluationFingerprint: string;
|
|
115
|
+
eligibleSubjectReferences: readonly string[];
|
|
116
|
+
communicationRequestEntryId: string;
|
|
117
|
+
communicationRequestIdentifier: string;
|
|
118
|
+
category: ClinicalCoding;
|
|
119
|
+
}>): GovernedAlertCampaignPlan;
|
|
120
|
+
/** Rechecks frozen membership before recording the data-layer delivery audit. */
|
|
121
|
+
export declare function prepareGovernedAlertDelivery(input: Readonly<{
|
|
122
|
+
campaignPlan: GovernedAlertCampaignPlan;
|
|
123
|
+
recipientReference: string;
|
|
124
|
+
communicationReference: string;
|
|
125
|
+
evaluatedAt: string;
|
|
126
|
+
eligibilityCurrent: boolean;
|
|
127
|
+
exclusionCurrent: boolean;
|
|
128
|
+
consentReference?: string;
|
|
129
|
+
channelAuthorizationReference?: string;
|
|
130
|
+
outcome: 'sent' | 'suppressed';
|
|
131
|
+
}>): GovernedAlertDeliveryAudit;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { freezeGovernedAlertCohort, normalizeCareJourneyTransition, normalizeEmergencyIntake, normalizeGovernedAlertCampaign, recordGovernedAlertDelivery, } from 'sos-data-utils-ts/care-emergency-alert-records';
|
|
2
|
+
import { TaskClaim, TaskStatus } from 'sos-data-utils-ts/task-artifact-schema';
|
|
3
|
+
import { buildCohortCommunicationRequestFlatEntry, buildEncounterCareJourneyFlatGraph, buildServiceRequestFlatEntry, buildServiceRequestTriageTaskFlatEntry, } from 'fhir-data-utils-ts/care-emergency-alert';
|
|
4
|
+
/**
|
|
5
|
+
* Plans one idempotent Appointment-to-Encounter transition. Persistence must
|
|
6
|
+
* enforce `idempotencyKey`; an existing reference suppresses duplicate writes.
|
|
7
|
+
*/
|
|
8
|
+
export function orchestrateFulfilledAppointment(input) {
|
|
9
|
+
const transition = normalizeCareJourneyTransition(input.transition);
|
|
10
|
+
if (input.existingEncounterReference) {
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
created: false,
|
|
13
|
+
encounterReference: reference(input.existingEncounterReference, 'Encounter', 'existing_encounter_reference_invalid'),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
const encounterEntry = buildEncounterCareJourneyFlatGraph({
|
|
17
|
+
parent: {
|
|
18
|
+
entryId: transition.encounterEntryId,
|
|
19
|
+
identifier: input.encounterIdentifier,
|
|
20
|
+
status: 'in-progress',
|
|
21
|
+
subjectReference: transition.subjectReference,
|
|
22
|
+
appointmentReference: transition.appointmentReference,
|
|
23
|
+
serviceProviderReference: transition.serviceProviderReference,
|
|
24
|
+
periodStart: transition.occurredAt,
|
|
25
|
+
locationReference: transition.locationReference,
|
|
26
|
+
},
|
|
27
|
+
})[0];
|
|
28
|
+
return Object.freeze({ created: true, encounterReference: encounterEntry.reference, encounterEntry });
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Authorizes only the channel boundary. It never attests clinical data.
|
|
32
|
+
* Preliminary encrypted Bundles may be delivered directly but cannot update
|
|
33
|
+
* the subject index; voice/video signaling cannot carry a clinical Bundle.
|
|
34
|
+
*/
|
|
35
|
+
export function authorizeEncounterCommunication(input) {
|
|
36
|
+
if (!input.actorCanCommunicate)
|
|
37
|
+
throw new Error('encounter_communication_actor_forbidden');
|
|
38
|
+
if (!input.encounterActive)
|
|
39
|
+
throw new Error('encounter_communication_inactive_encounter');
|
|
40
|
+
if ((input.channel === 'voice' || input.channel === 'video') && input.includesClinicalBundle) {
|
|
41
|
+
throw new Error('encounter_call_clinical_bundle_forbidden');
|
|
42
|
+
}
|
|
43
|
+
if (input.includesClinicalBundle && !input.clinicalStatus)
|
|
44
|
+
throw new Error('encounter_communication_clinical_status_required');
|
|
45
|
+
if (input.clinicalStatus === 'preliminary' && input.updateIndex)
|
|
46
|
+
throw new Error('preliminary_clinical_index_update_forbidden');
|
|
47
|
+
const recipientReferences = uniqueReferences(input.recipientReferences, 'encounter_communication_recipient_required');
|
|
48
|
+
return Object.freeze({
|
|
49
|
+
encounterReference: reference(input.encounterReference, 'Encounter', 'encounter_communication_encounter_reference_invalid'),
|
|
50
|
+
actorReference: reference(input.actorReference, undefined, 'encounter_communication_actor_reference_invalid'),
|
|
51
|
+
recipientReferences,
|
|
52
|
+
channel: input.channel,
|
|
53
|
+
includesClinicalBundle: input.includesClinicalBundle,
|
|
54
|
+
...(input.clinicalStatus ? { clinicalStatus: input.clinicalStatus } : {}),
|
|
55
|
+
updateIndex: input.updateIndex,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** Creates independent standard ServiceRequest and durable triage Task entries. */
|
|
59
|
+
export function orchestrateEmergencyRequest(input) {
|
|
60
|
+
const intake = normalizeEmergencyIntake(input.intake);
|
|
61
|
+
const triageGroupReference = reference(intake.triageOwnerReference, 'Group', 'emergency_triage_group_owner_required');
|
|
62
|
+
const serviceRequestEntry = buildServiceRequestFlatEntry({
|
|
63
|
+
entryId: intake.serviceRequestEntryId,
|
|
64
|
+
identifier: input.serviceRequestIdentifier,
|
|
65
|
+
status: 'active',
|
|
66
|
+
intent: 'order',
|
|
67
|
+
priority: 'stat',
|
|
68
|
+
code: input.assistanceCode,
|
|
69
|
+
subjectReference: intake.subjectReference,
|
|
70
|
+
requesterReference: intake.requesterReference,
|
|
71
|
+
performerReferences: [intake.targetServiceReference],
|
|
72
|
+
authoredAt: intake.receivedAt,
|
|
73
|
+
});
|
|
74
|
+
const baseTriageTaskEntry = buildServiceRequestTriageTaskFlatEntry({
|
|
75
|
+
entryId: intake.triageTaskEntryId,
|
|
76
|
+
identifier: input.triageTaskIdentifier,
|
|
77
|
+
serviceRequestReference: serviceRequestEntry.reference,
|
|
78
|
+
subjectReference: intake.subjectReference,
|
|
79
|
+
requesterReference: intake.requesterReference,
|
|
80
|
+
ownerReference: triageGroupReference,
|
|
81
|
+
authoredAt: intake.receivedAt,
|
|
82
|
+
});
|
|
83
|
+
const triageTaskEntry = Object.freeze({
|
|
84
|
+
...baseTriageTaskEntry,
|
|
85
|
+
claims: Object.freeze({
|
|
86
|
+
...baseTriageTaskEntry.claims,
|
|
87
|
+
[TaskClaim.GroupIdentifier]: intake.communicationThreadId,
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
return Object.freeze({ intake, serviceRequestEntry, triageTaskEntry });
|
|
91
|
+
}
|
|
92
|
+
// Compatibility overlay for the published data package used by clean installs.
|
|
93
|
+
// The next grouped release exposes the same standard values directly there.
|
|
94
|
+
const EmergencyTaskStatus = Object.freeze({
|
|
95
|
+
...TaskStatus,
|
|
96
|
+
Received: 'received',
|
|
97
|
+
Accepted: 'accepted',
|
|
98
|
+
Rejected: 'rejected',
|
|
99
|
+
Ready: 'ready',
|
|
100
|
+
OnHold: 'on-hold',
|
|
101
|
+
EnteredInError: 'entered-in-error',
|
|
102
|
+
});
|
|
103
|
+
/**
|
|
104
|
+
* Reads only the standard, claims-first coordinates required by a responder
|
|
105
|
+
* console. It does not resolve contact endpoints or grant callback authority.
|
|
106
|
+
*/
|
|
107
|
+
export function readEmergencyTriageTask(taskEntry) {
|
|
108
|
+
if (taskEntry.resourceType !== 'Task' || taskEntry.reference !== `Task/${taskEntry.entryId}`) {
|
|
109
|
+
throw new TypeError('emergency_triage_task_invalid');
|
|
110
|
+
}
|
|
111
|
+
const claims = taskEntry.claims;
|
|
112
|
+
const status = scalarClaim(claims[TaskClaim.Status], 'emergency_triage_status_required');
|
|
113
|
+
if (!Object.values(EmergencyTaskStatus).includes(status)) {
|
|
114
|
+
throw new TypeError('emergency_triage_status_invalid');
|
|
115
|
+
}
|
|
116
|
+
return Object.freeze({
|
|
117
|
+
taskReference: taskEntry.reference,
|
|
118
|
+
status,
|
|
119
|
+
focusReference: reference(scalarClaim(claims[TaskClaim.Focus], 'emergency_triage_focus_required'), 'ServiceRequest', 'emergency_triage_focus_invalid'),
|
|
120
|
+
subjectReference: reference(scalarClaim(claims[TaskClaim.For], 'emergency_triage_subject_required'), undefined, 'emergency_triage_subject_invalid'),
|
|
121
|
+
requesterReference: reference(scalarClaim(claims[TaskClaim.Requester], 'emergency_triage_requester_required'), undefined, 'emergency_triage_requester_invalid'),
|
|
122
|
+
ownerReference: reference(scalarClaim(claims[TaskClaim.Owner], 'emergency_triage_owner_required'), undefined, 'emergency_triage_owner_invalid'),
|
|
123
|
+
authoredAt: dateTime(scalarClaim(claims[TaskClaim.AuthoredOn], 'emergency_triage_authored_on_required'), 'emergency_triage_authored_on_invalid'),
|
|
124
|
+
...(claims[TaskClaim.GroupIdentifier]
|
|
125
|
+
? { communicationThreadId: scalarClaim(claims[TaskClaim.GroupIdentifier], 'emergency_triage_thread_invalid') }
|
|
126
|
+
: {}),
|
|
127
|
+
...(claims[TaskClaim.LastModified]
|
|
128
|
+
? { lastModified: dateTime(scalarClaim(claims[TaskClaim.LastModified], 'emergency_triage_last_modified_invalid'), 'emergency_triage_last_modified_invalid') }
|
|
129
|
+
: {}),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Applies one deterministic responder transition to the Task only. The
|
|
134
|
+
* ServiceRequest remains immutable; callers must authorize the responder and
|
|
135
|
+
* resolve any escalation owner before invoking this function.
|
|
136
|
+
*/
|
|
137
|
+
export function transitionEmergencyTriageTask(input) {
|
|
138
|
+
const current = readEmergencyTriageTask(input.taskEntry);
|
|
139
|
+
const responderReference = reference(input.responderReference, 'PractitionerRole', 'emergency_triage_responder_invalid');
|
|
140
|
+
const transition = emergencyTriageTransition(current.status, input.action);
|
|
141
|
+
const ownerReference = input.action === 'escalate'
|
|
142
|
+
? reference(input.escalationOwnerReference, undefined, 'emergency_triage_escalation_owner_required')
|
|
143
|
+
: input.action === 'acknowledge'
|
|
144
|
+
? responderReference
|
|
145
|
+
: current.ownerReference;
|
|
146
|
+
const claims = {
|
|
147
|
+
...input.taskEntry.claims,
|
|
148
|
+
[TaskClaim.Status]: transition,
|
|
149
|
+
[TaskClaim.Owner]: ownerReference,
|
|
150
|
+
[TaskClaim.LastModified]: dateTime(input.occurredAt, 'emergency_triage_occurred_at_invalid'),
|
|
151
|
+
...(input.action === 'escalate' ? { [TaskClaim.EscalationRecipient]: ownerReference } : {}),
|
|
152
|
+
};
|
|
153
|
+
return Object.freeze({ ...input.taskEntry, claims: Object.freeze(claims) });
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Authorizes callback correlation for the current Task owner. Protected
|
|
157
|
+
* endpoint resolution and WebRTC signaling remain in the product server.
|
|
158
|
+
*/
|
|
159
|
+
export function prepareEmergencyResponderCallback(input) {
|
|
160
|
+
const task = readEmergencyTriageTask(input.taskEntry);
|
|
161
|
+
const responderReference = reference(input.responderReference, 'PractitionerRole', 'emergency_callback_responder_invalid');
|
|
162
|
+
if (task.ownerReference !== responderReference)
|
|
163
|
+
throw new TypeError('emergency_callback_task_owner_required');
|
|
164
|
+
if (task.status !== EmergencyTaskStatus.Accepted && task.status !== EmergencyTaskStatus.InProgress) {
|
|
165
|
+
throw new TypeError('emergency_callback_active_task_required');
|
|
166
|
+
}
|
|
167
|
+
if (!task.communicationThreadId)
|
|
168
|
+
throw new TypeError('emergency_callback_thread_required');
|
|
169
|
+
if (input.media !== 'audio' && input.media !== 'video')
|
|
170
|
+
throw new TypeError('emergency_callback_media_invalid');
|
|
171
|
+
return Object.freeze({
|
|
172
|
+
taskReference: task.taskReference,
|
|
173
|
+
serviceRequestReference: task.focusReference,
|
|
174
|
+
subjectReference: task.subjectReference,
|
|
175
|
+
requesterReference: task.requesterReference,
|
|
176
|
+
responderReference,
|
|
177
|
+
communicationThreadId: task.communicationThreadId,
|
|
178
|
+
media: input.media,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
function emergencyTriageTransition(currentStatus, action) {
|
|
182
|
+
const transitions = {
|
|
183
|
+
acknowledge: { [EmergencyTaskStatus.Requested]: EmergencyTaskStatus.Accepted, [EmergencyTaskStatus.Received]: EmergencyTaskStatus.Accepted },
|
|
184
|
+
start: { [EmergencyTaskStatus.Accepted]: EmergencyTaskStatus.InProgress, [EmergencyTaskStatus.Ready]: EmergencyTaskStatus.InProgress },
|
|
185
|
+
escalate: {
|
|
186
|
+
[EmergencyTaskStatus.Requested]: EmergencyTaskStatus.Requested,
|
|
187
|
+
[EmergencyTaskStatus.Received]: EmergencyTaskStatus.Requested,
|
|
188
|
+
[EmergencyTaskStatus.Accepted]: EmergencyTaskStatus.Requested,
|
|
189
|
+
[EmergencyTaskStatus.InProgress]: EmergencyTaskStatus.Requested,
|
|
190
|
+
},
|
|
191
|
+
reject: {
|
|
192
|
+
[EmergencyTaskStatus.Requested]: EmergencyTaskStatus.Rejected,
|
|
193
|
+
[EmergencyTaskStatus.Received]: EmergencyTaskStatus.Rejected,
|
|
194
|
+
[EmergencyTaskStatus.Accepted]: EmergencyTaskStatus.Rejected,
|
|
195
|
+
},
|
|
196
|
+
complete: { [EmergencyTaskStatus.InProgress]: EmergencyTaskStatus.Completed },
|
|
197
|
+
};
|
|
198
|
+
const nextStatus = transitions[action]?.[currentStatus];
|
|
199
|
+
if (!nextStatus)
|
|
200
|
+
throw new TypeError('emergency_triage_transition_invalid');
|
|
201
|
+
return nextStatus;
|
|
202
|
+
}
|
|
203
|
+
/** Builds one campaign request bound to the frozen evaluated Group snapshot. */
|
|
204
|
+
export function prepareGovernedAlertCampaign(input) {
|
|
205
|
+
const campaign = normalizeGovernedAlertCampaign(input.campaign);
|
|
206
|
+
const cohortSnapshot = freezeGovernedAlertCohort({
|
|
207
|
+
campaign,
|
|
208
|
+
snapshotGroupReference: input.snapshotGroupReference,
|
|
209
|
+
evaluatedAt: input.evaluatedAt,
|
|
210
|
+
evaluationFingerprint: input.evaluationFingerprint,
|
|
211
|
+
eligibleSubjectReferences: input.eligibleSubjectReferences,
|
|
212
|
+
});
|
|
213
|
+
const communicationRequestEntry = buildCohortCommunicationRequestFlatEntry({
|
|
214
|
+
entryId: input.communicationRequestEntryId,
|
|
215
|
+
identifier: input.communicationRequestIdentifier,
|
|
216
|
+
status: 'active',
|
|
217
|
+
priority: 'routine',
|
|
218
|
+
category: input.category,
|
|
219
|
+
subjectReference: cohortSnapshot.snapshotGroupReference,
|
|
220
|
+
requesterReference: campaign.requesterReference,
|
|
221
|
+
authoredAt: cohortSnapshot.evaluatedAt,
|
|
222
|
+
reasonReferences: [campaign.groupDefinitionReference],
|
|
223
|
+
});
|
|
224
|
+
return Object.freeze({ campaign, cohortSnapshot, communicationRequestEntry });
|
|
225
|
+
}
|
|
226
|
+
/** Rechecks frozen membership before recording the data-layer delivery audit. */
|
|
227
|
+
export function prepareGovernedAlertDelivery(input) {
|
|
228
|
+
const recipientReference = reference(input.recipientReference, undefined, 'alert_delivery_recipient_reference_invalid');
|
|
229
|
+
if (!input.campaignPlan.cohortSnapshot.eligibleSubjectReferences.includes(recipientReference)) {
|
|
230
|
+
throw new Error('alert_delivery_recipient_not_in_snapshot');
|
|
231
|
+
}
|
|
232
|
+
return recordGovernedAlertDelivery({
|
|
233
|
+
campaignId: input.campaignPlan.campaign.campaignId,
|
|
234
|
+
snapshotGroupReference: input.campaignPlan.cohortSnapshot.snapshotGroupReference,
|
|
235
|
+
recipientReference,
|
|
236
|
+
communicationReference: input.communicationReference,
|
|
237
|
+
evaluatedAt: input.evaluatedAt,
|
|
238
|
+
eligibilityCurrent: input.eligibilityCurrent,
|
|
239
|
+
exclusionCurrent: input.exclusionCurrent,
|
|
240
|
+
consentReference: input.consentReference,
|
|
241
|
+
channelAuthorizationReference: input.channelAuthorizationReference,
|
|
242
|
+
outcome: input.outcome,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
function scalarClaim(value, error) {
|
|
246
|
+
if (typeof value !== 'string' || !value.trim())
|
|
247
|
+
throw new TypeError(error);
|
|
248
|
+
return value.trim();
|
|
249
|
+
}
|
|
250
|
+
function dateTime(value, error) {
|
|
251
|
+
const normalized = scalarClaim(value, error);
|
|
252
|
+
if (!/^\d{4}-\d{2}-\d{2}T/u.test(normalized) || Number.isNaN(Date.parse(normalized))) {
|
|
253
|
+
throw new TypeError(error);
|
|
254
|
+
}
|
|
255
|
+
return normalized;
|
|
256
|
+
}
|
|
257
|
+
function uniqueReferences(values, error) {
|
|
258
|
+
const references = [...new Set(values.map(value => reference(value, undefined, error)))];
|
|
259
|
+
if (!references.length)
|
|
260
|
+
throw new TypeError(error);
|
|
261
|
+
return Object.freeze(references);
|
|
262
|
+
}
|
|
263
|
+
function reference(value, expectedType, error) {
|
|
264
|
+
if (typeof value !== 'string')
|
|
265
|
+
throw new TypeError(error);
|
|
266
|
+
const normalized = value.trim();
|
|
267
|
+
const match = /^([A-Z][A-Za-z0-9]+)\/([A-Za-z0-9.-]{1,128})$/.exec(normalized);
|
|
268
|
+
if (!match || (expectedType && match[1] !== expectedType))
|
|
269
|
+
throw new TypeError(error);
|
|
270
|
+
return normalized;
|
|
271
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,5 +3,6 @@ export { EmergencyCapabilities, EvidenceDomains, EvidenceRelationships, Soschain
|
|
|
3
3
|
export type { EvidenceRelationship, SoschainEvidenceRelationship, } from 'sos-data-utils-ts/emergency-evidence';
|
|
4
4
|
export * from './emergency-group.js';
|
|
5
5
|
export * from './emergency-authorization.js';
|
|
6
|
+
export * from './care-emergency-alert-orchestration.js';
|
|
6
7
|
export * from './place-card-association.js';
|
|
7
8
|
export * from './secure-message-crypto.js';
|
package/dist/index.js
CHANGED
|
@@ -2,5 +2,6 @@
|
|
|
2
2
|
export { EmergencyCapabilities, EvidenceDomains, EvidenceRelationships, SoschainEmergencyCapabilities, SoschainEvidenceDomains, SoschainEvidenceRelationships, } from 'sos-data-utils-ts/emergency-evidence';
|
|
3
3
|
export * from './emergency-group.js';
|
|
4
4
|
export * from './emergency-authorization.js';
|
|
5
|
+
export * from './care-emergency-alert-orchestration.js';
|
|
5
6
|
export * from './place-card-association.js';
|
|
6
7
|
export * from './secure-message-crypto.js';
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Care, Emergency and Alert Orchestration
|
|
2
|
+
|
|
3
|
+
This package owns runtime-neutral authorization and orchestration shared by
|
|
4
|
+
VetChain, UHC and SOSChain. FHIR flat-claim builders belong to
|
|
5
|
+
`fhir-data-utils-ts`; deterministic records belong to `sos-data-utils-ts`;
|
|
6
|
+
transport and persistence belong to BFF/channel and Node services; animal and
|
|
7
|
+
human policy stays in Vet and UHC.
|
|
8
|
+
|
|
9
|
+
## 1. Care continuity and protected contact
|
|
10
|
+
|
|
11
|
+
1. Appointment `fulfilled` -> Encounter is an idempotent transition.
|
|
12
|
+
2. The Encounter preserves the selected subject, Appointment, service-provider
|
|
13
|
+
Organization, start time and current Location.
|
|
14
|
+
3. Admission, diagnostics, intensive care, ward and discharge are represented
|
|
15
|
+
by Encounter/location continuity. Controllers see only what current Consent
|
|
16
|
+
and actor/subject authorization permit.
|
|
17
|
+
4. An authorized practitioner may start voice/video contact or send an
|
|
18
|
+
encrypted message to the controller or permitted related recipients before
|
|
19
|
+
publishing stressful results.
|
|
20
|
+
5. Voice/video signaling carries no clinical Bundle. A preliminary clinical
|
|
21
|
+
Bundle may be delivered by encrypted message only with `updateIndex=false`.
|
|
22
|
+
A call or preliminary message must not publish, attest or update the subject
|
|
23
|
+
index. Final clinical publication is a separate workflow.
|
|
24
|
+
6. A missed call may create an encrypted text/audio message in the same thread;
|
|
25
|
+
its recipients and thread authorization are revalidated by the service.
|
|
26
|
+
|
|
27
|
+
The orchestrator returns a plan. The persistence owner must enforce the
|
|
28
|
+
idempotency key and must not treat a returned plan as authorization evidence.
|
|
29
|
+
|
|
30
|
+
## 2. Resilient emergency assistance
|
|
31
|
+
|
|
32
|
+
When normal telephone service is unavailable, an authenticated controller or
|
|
33
|
+
authorized requester submits an emergency request to a HealthcareService or
|
|
34
|
+
its owning Organization. The BFF resolves the official target and creates two
|
|
35
|
+
independent resources:
|
|
36
|
+
|
|
37
|
+
- a compact-coded ServiceRequest containing the care request, requester,
|
|
38
|
+
subject and performer target;
|
|
39
|
+
- a Task containing durable intake/triage ownership and linking back through
|
|
40
|
+
`Task.focus`. Its initial owner is the professional Group assigned to the
|
|
41
|
+
target department/service.
|
|
42
|
+
|
|
43
|
+
The emergency console can claim and update the Task, then open an authorized
|
|
44
|
+
voice/video callback or encrypted thread. Raw phone/email destinations,
|
|
45
|
+
WebRTC SDP/ICE/TURN state and encryption keys never enter either FHIR resource.
|
|
46
|
+
The request grants no read access by itself; subject access still requires the
|
|
47
|
+
current emergency or ordinary authorization contract.
|
|
48
|
+
|
|
49
|
+
The high-level API keeps reads and writes explicit:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
const triageTask = readEmergencyTriageTask(claimsFirstTaskEntry)
|
|
53
|
+
const acceptedTaskEntry = transitionEmergencyTriageTask({
|
|
54
|
+
taskEntry: claimsFirstTaskEntry,
|
|
55
|
+
action: 'acknowledge',
|
|
56
|
+
responderReference: authenticatedPractitionerRoleReference,
|
|
57
|
+
occurredAt: serverTime,
|
|
58
|
+
})
|
|
59
|
+
const callback = prepareEmergencyResponderCallback({
|
|
60
|
+
taskEntry: acceptedTaskEntry,
|
|
61
|
+
responderReference: authenticatedPractitionerRoleReference,
|
|
62
|
+
media: 'audio',
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`transitionEmergencyTriageTask(...)` changes only the Task. It never rewrites
|
|
67
|
+
the ServiceRequest. Acknowledgement assigns the authenticated responder;
|
|
68
|
+
escalation requires a server-resolved owner and returns the Task to
|
|
69
|
+
`requested`. Callback authorization requires the accepted/in-progress Task
|
|
70
|
+
owner and returns only stable references plus the protected thread
|
|
71
|
+
correlation. The product server must still resolve the current encrypted
|
|
72
|
+
recipient endpoints and signaling transport.
|
|
73
|
+
|
|
74
|
+
The initial Group is the service queue, not a document author or a human
|
|
75
|
+
sender. Once accepted, `Task.owner` is the acting `PractitionerRole`; that role
|
|
76
|
+
is also the Communication/call sender. Controller/member replies are addressed
|
|
77
|
+
to the stable department Group route retained by the protected thread. The
|
|
78
|
+
legal Organization remains the author of any later clinical document, and its
|
|
79
|
+
professional attester and authenticated transport actor remain separate.
|
|
80
|
+
|
|
81
|
+
## 3. Governed alerts
|
|
82
|
+
|
|
83
|
+
The common flow supports both `blood-donation` and `public-risk` campaigns.
|
|
84
|
+
The server evaluates a governed definitional Group, freezes an enumerated Group
|
|
85
|
+
snapshot and binds a CommunicationRequest to that snapshot. Subscription wakes
|
|
86
|
+
evaluation; it is not Consent, cohort-query authority or send authority.
|
|
87
|
+
|
|
88
|
+
Blood-donation campaigns require compact `system|code` blood-group filters and
|
|
89
|
+
the minimum elapsed days since the last donation. Public-risk campaigns require
|
|
90
|
+
server-authorized FHIR `resourceType`, `searchParameter` and `value` criteria;
|
|
91
|
+
clients cannot submit executable queries.
|
|
92
|
+
|
|
93
|
+
Immediately before each delivery, the service must verify snapshot membership,
|
|
94
|
+
current eligibility, current exclusions, Consent and channel authorization.
|
|
95
|
+
Only then may it create/send the Communication and record the audit. Group and
|
|
96
|
+
CommunicationRequest resources never replace those per-recipient checks.
|
|
97
|
+
|
|
98
|
+
## Release proof
|
|
99
|
+
|
|
100
|
+
Merge validated shared dependency sources to `main`, create immutable
|
|
101
|
+
unpublished tarballs from those commits, and install them temporarily with
|
|
102
|
+
`--no-save`. Prove the affected real local Vet channel (portal, phone service,
|
|
103
|
+
chatbot or another supported channel) -> BFF/channel service -> high-level SDK
|
|
104
|
+
-> GW VET journey without skips. Merge the validated consumer source to
|
|
105
|
+
`main`, publish exact packages bottom-up, pin registry versions, repeat affected
|
|
106
|
+
local gates, and only then promote to staging. Never commit local tarballs or
|
|
107
|
+
local dependency references.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sos-sdk-core-ts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Runtime-neutral shared orchestration for SOS, Vet and UHC products",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Connecting Solution & Applications Ltd",
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
"types": "./dist/emergency-group.d.ts",
|
|
21
21
|
"default": "./dist/emergency-group.js"
|
|
22
22
|
},
|
|
23
|
+
"./care-emergency-alert-orchestration": {
|
|
24
|
+
"types": "./dist/care-emergency-alert-orchestration.d.ts",
|
|
25
|
+
"default": "./dist/care-emergency-alert-orchestration.js"
|
|
26
|
+
},
|
|
23
27
|
"./emergency-authorization": {
|
|
24
28
|
"types": "./dist/emergency-authorization.d.ts",
|
|
25
29
|
"default": "./dist/emergency-authorization.js"
|
|
@@ -51,7 +55,8 @@
|
|
|
51
55
|
"@noble/ciphers": "2.4.0",
|
|
52
56
|
"@noble/hashes": "2.4.0",
|
|
53
57
|
"@noble/post-quantum": "0.7.1",
|
|
54
|
-
"
|
|
58
|
+
"fhir-data-utils-ts": "0.3.7",
|
|
59
|
+
"sos-data-utils-ts": "0.4.2"
|
|
55
60
|
},
|
|
56
61
|
"devDependencies": {
|
|
57
62
|
"@types/node": "^22.5.0",
|