vet-sdk-core-ts 0.4.13 → 0.4.15

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/README.md CHANGED
@@ -8,12 +8,13 @@ owns gateway request construction. GW VET remains the policy authority.
8
8
 
9
9
  ## Research studies
10
10
 
11
- `buildVeterinaryResearchStudyCreateEntry` preserves a native FHIR R5
12
- ResearchStudy and derives its standard flat search claims. Associated parties
13
- are retained as complete objects. Later changes use one independent PATCH
14
- entry per party, so the party reference, role, periods and classifiers cannot
15
- be mixed across employees. `buildVeterinaryResearchStudyBatch` wraps these
16
- entries in JSON:API `data[]` for GW VET.
11
+ `buildVeterinaryResearchStudyCreateEntry` accepts ResearchStudy business data
12
+ and projects it into the canonical JSON:API resource object: `resourceType`,
13
+ `id` and flat `resource.meta.claims`. It does not send native `status`, `title`,
14
+ `associatedParty` or `recruitment` objects to GW. Associated parties use one
15
+ independent flat-claim PATCH entry per party, so references, roles, periods and
16
+ classifiers cannot be mixed across employees. Native FHIR JSON is produced
17
+ only by an explicit later export/projection boundary.
17
18
 
18
19
  Study participation does not itself authorize access. The separate
19
20
  `buildVeterinaryResearchStudySmartAuthorization` request is limited to create,
@@ -33,26 +34,31 @@ separate JSON:API `Consent/_batch`. Every POST is fixed to `active`, `permit`,
33
34
  ResearchStudy scope. The API does not accept caller-authored actions, so it
34
35
  cannot be broadened to delete.
35
36
 
36
- `buildVeterinaryResearchStudyPartyInvitations` creates exactly one R5
37
- `Communication` per associated `PractitionerRole`. Each Communication carries
38
- an `application/fhir+json` batch Bundle with exactly one draft Consent, scoped
39
- to the ResearchStudy through `Consent.provision.data.reference` and to
40
- `ResearchSubject` through `Consent.provision.resourceType`. The draft requests
41
- only create, read, update and search. It contains no delete permission and
42
- never authorizes SMART access.
37
+ `buildVeterinaryResearchStudyPartyInvitations` creates exactly one claims-only
38
+ `Communication` per associated `PractitionerRole`. Its canonical attachment
39
+ claims carry an `application/vnd.api+json` primary document containing one
40
+ JSON:API resource object with `type`, `id` and flat Consent claims in
41
+ `attributes`; `resource`, `request` and FHIR Bundle shapes are rejected.
42
+ `Consent.source-reference` pins the ResearchStudy
43
+ and `Consent.resource-type` pins `ResearchSubject`. The draft requests only
44
+ create, read, update and search; it contains no delete permission and never
45
+ authorizes SMART access. Native FHIR Communication, Consent or Bundle objects
46
+ are accepted only by explicit import/projection/export adapters.
43
47
 
44
48
  `buildVeterinaryResearchStudyGroupCreateEntry` and
45
- `buildVeterinaryResearchStudyGroupMemberPatchEntries` build the native R5 Group
49
+ `buildVeterinaryResearchStudyGroupMemberPatchEntries` build flat-claim Group
46
50
  POST/PATCH entries for an enumerated professional review team. Every member is
47
51
  a registered `PractitionerRole`; the exact twelve Group search claims are
48
- derived from `vet-data-utils-ts/group`. The ResearchStudy points to the team
49
- through the reference returned by
50
- `buildVeterinaryResearchStudyEligibilityGroupReference`, used as
51
- `ResearchStudy.recruitment.eligibility`.
52
- `buildVeterinaryResearchStudyEligibilityGroupPatchEntry` links an existing
53
- study and is wrapped by `buildVeterinaryResearchStudyBatch`; it does not invent
54
- a mixed-resource gateway batch. Group itself contains no reverse study link
55
- and grants no authorization, permission or SMART scope.
52
+ derived from `vet-data-utils-ts/group`, and each member has its own PATCH entry.
53
+ The Group stays independent: individual professionals are related to the study
54
+ through separate `ResearchStudy.associatedParty` PATCH entries. It must not use
55
+ `ResearchStudy.recruitment.eligibility`, which represents research-subject
56
+ cohorts or eligibility criteria. Group grants no authorization, permission or
57
+ SMART scope; DCR and active Consent remain separate.
58
+ The Group create builder requires the ResearchStudy UUID and derives a
59
+ governed `Group.identifier`. Its exported search helper re-derives
60
+ the same deterministic UUIDv5 review-team identifier after refresh without
61
+ using eligibility or granting authority.
56
62
 
57
63
  The complete controller-to-professional contract and a browser-safe snippet
58
64
  are in
@@ -1,5 +1,5 @@
1
- import { type ResearchStudyAssociatedParty } from 'vet-data-utils-ts/research-study';
2
- import { type GroupR5ResearchTeam } from 'vet-data-utils-ts/group';
1
+ import { type ResearchStudyAssociatedParty, type ResearchStudyR5FlatClaimsResource } from 'vet-data-utils-ts/research-study';
2
+ import { type GroupR5FlatClaimsResource, type GroupR5ResearchTeam } from 'vet-data-utils-ts/group';
3
3
  import { HealthcareConsentPurposes } from 'gdc-common-utils-ts/constants/healthcare';
4
4
  import { ResourceTypesFhirR5 } from 'gdc-common-utils-ts/constants/fhir-resource-types';
5
5
  import { ClaimConsent, ConsentStatuses } from 'gdc-common-utils-ts/models/consent-rule';
@@ -14,72 +14,23 @@ export declare const VeterinaryResearchStudyInvitationClaimNames: Readonly<{
14
14
  readonly Action: ClaimConsent.action;
15
15
  readonly ResourceType: ClaimConsent.resourceType;
16
16
  }>;
17
- type FhirReference = Readonly<{
18
- reference: string;
19
- type?: string;
20
- }>;
21
- type FhirCoding = Readonly<{
22
- system: string;
23
- code: string;
24
- }>;
25
- type FhirCodeableConcept = Readonly<{
26
- coding: readonly FhirCoding[];
27
- }>;
28
17
  export type VeterinaryResearchStudyDraftConsent = Readonly<{
29
- resourceType: 'Consent';
18
+ type: 'Consent-v5.0.0';
30
19
  id: string;
31
- status: 'draft';
32
- grantor: readonly FhirReference[];
33
- controller: readonly FhirReference[];
34
- grantee: readonly FhirReference[];
35
- decision: 'permit';
36
- provision: readonly Readonly<{
37
- actor: readonly Readonly<{
38
- reference: FhirReference;
39
- }>[];
40
- action: readonly FhirCodeableConcept[];
41
- purpose: readonly FhirCoding[];
42
- resourceType: readonly FhirCoding[];
43
- data: readonly Readonly<{
44
- meaning: 'related';
45
- reference: FhirReference;
46
- }>[];
47
- }>[];
48
- meta: Readonly<{
49
- claims: Readonly<Record<string, string>>;
50
- }>;
20
+ attributes: Readonly<Record<string, string>>;
51
21
  }>;
52
- export type VeterinaryResearchStudyInvitationBundle = Readonly<{
53
- resourceType: 'Bundle';
54
- type: 'batch';
55
- entry: readonly Readonly<{
56
- fullUrl: string;
57
- resource: VeterinaryResearchStudyDraftConsent;
58
- request: Readonly<{
59
- method: 'POST';
60
- url: 'Consent';
61
- }>;
62
- }>[];
22
+ export type VeterinaryResearchStudyInvitationDocument = Readonly<{
23
+ data: readonly VeterinaryResearchStudyDraftConsent[];
63
24
  }>;
64
25
  export type VeterinaryResearchStudyPartyInvitation = Readonly<{
65
26
  resourceType: 'Communication';
66
27
  id: string;
67
- status: 'completed';
68
- category: readonly FhirCodeableConcept[];
69
- topic: FhirCodeableConcept;
70
- about: readonly FhirReference[];
71
- recipient: readonly FhirReference[];
72
- sender: FhirReference;
73
- sent?: string;
74
- payload: readonly Readonly<{
75
- contentAttachment: Readonly<{
76
- contentType: 'application/fhir+json';
77
- title: 'research-study-draft-consent.json';
78
- data: string;
79
- }>;
80
- }>[];
28
+ meta: Readonly<{
29
+ claims: Readonly<Record<string, string>>;
30
+ }>;
81
31
  }>;
82
- export type VeterinaryResearchStudyResource = Readonly<{
32
+ /** Native-shaped builder input used only to derive the persisted flat claims. */
33
+ export type VeterinaryResearchStudySourceResource = Readonly<{
83
34
  resourceType: 'ResearchStudy';
84
35
  id: string;
85
36
  status?: 'draft' | 'active' | 'retired' | 'unknown';
@@ -87,9 +38,10 @@ export type VeterinaryResearchStudyResource = Readonly<{
87
38
  meta?: Readonly<Record<string, unknown>>;
88
39
  [key: string]: unknown;
89
40
  }>;
90
- export type VeterinaryResearchStudyCreateResource = VeterinaryResearchStudyResource & Readonly<{
91
- status: NonNullable<VeterinaryResearchStudyResource['status']>;
41
+ export type VeterinaryResearchStudyCreateResource = VeterinaryResearchStudySourceResource & Readonly<{
42
+ status: NonNullable<VeterinaryResearchStudySourceResource['status']>;
92
43
  }>;
44
+ export type VeterinaryResearchStudyResource = ResearchStudyR5FlatClaimsResource;
93
45
  export type VeterinaryResearchStudyBatchEntry = Readonly<{
94
46
  type: 'ResearchStudy-v5.0.0';
95
47
  resource: VeterinaryResearchStudyResource;
@@ -101,13 +53,7 @@ export type VeterinaryResearchStudyBatchEntry = Readonly<{
101
53
  export type VeterinaryResearchStudyBatch = Readonly<{
102
54
  data: readonly VeterinaryResearchStudyBatchEntry[];
103
55
  }>;
104
- export type VeterinaryResearchStudyGroupResource = GroupR5ResearchTeam & Readonly<{
105
- id: string;
106
- meta: Readonly<{
107
- claims: Readonly<Record<string, readonly string[]>>;
108
- [key: string]: unknown;
109
- }>;
110
- }>;
56
+ export type VeterinaryResearchStudyGroupResource = GroupR5FlatClaimsResource;
111
57
  export type VeterinaryResearchStudyGroupBatchEntry = Readonly<{
112
58
  type: 'Group-v5.0.0';
113
59
  resource: VeterinaryResearchStudyGroupResource;
@@ -119,6 +65,19 @@ export type VeterinaryResearchStudyGroupBatchEntry = Readonly<{
119
65
  export type VeterinaryResearchStudyGroupBatch = Readonly<{
120
66
  data: readonly VeterinaryResearchStudyGroupBatchEntry[];
121
67
  }>;
68
+ /**
69
+ * Canonical URI Identifier.system linking one descriptive review-team Group
70
+ * to its study through an RFC 9562 UUIDv5 value.
71
+ *
72
+ * @see https://hl7.org/fhir/R5/datatypes.html#Identifier
73
+ * @see https://www.rfc-editor.org/rfc/rfc9562#name-uuid-version-5
74
+ */
75
+ export declare const VeterinaryResearchStudyReviewTeamIdentifierSystem: "urn:ietf:rfc:3986";
76
+ /** UUIDv5 namespace governed by this SDK for ResearchStudy review-team identifiers. */
77
+ export declare const VeterinaryResearchStudyReviewTeamIdentifierNamespace: "6434afe2-d152-5ec2-a88b-ec2a96e0f010";
78
+ export type VeterinaryResearchStudyReviewTeamGroupSearch = Readonly<{
79
+ identifier: string;
80
+ }>;
122
81
  export type VeterinaryResearchStudySmartAuthorization = Readonly<{
123
82
  purpose: typeof HealthcareConsentPurposes.Research;
124
83
  researchStudyReference: string;
@@ -135,7 +94,6 @@ export type VeterinaryResearchStudyActiveConsentInput = Readonly<{
135
94
  export type VeterinaryResearchStudyActiveConsent = Readonly<{
136
95
  resourceType: typeof ResourceTypesFhirR5.Consent;
137
96
  id: string;
138
- status: typeof ConsentStatuses.Active;
139
97
  meta: Readonly<{
140
98
  claims: Readonly<Record<string, string>>;
141
99
  }>;
@@ -179,46 +137,34 @@ export declare function buildVeterinaryResearchStudySmartAuthorization(input: Re
179
137
  * @see https://hl7.org/fhir/R5/researchstudy.html
180
138
  */
181
139
  export declare function buildVeterinaryResearchStudyActiveConsentBatch(inputs: readonly VeterinaryResearchStudyActiveConsentInput[]): VeterinaryResearchStudyActiveConsentBatch;
182
- /** Builds one POST Consent entry accepted by the VET ResearchStudy manager. */
140
+ /** Builds one flat-claims POST Consent entry accepted by the VET ResearchStudy manager. */
183
141
  export declare function buildVeterinaryResearchStudyActiveConsentEntry(input: VeterinaryResearchStudyActiveConsentInput): VeterinaryResearchStudyActiveConsentEntry;
184
142
  export declare function buildVeterinaryResearchStudyBatch(entries: readonly VeterinaryResearchStudyBatchEntry[]): VeterinaryResearchStudyBatch;
185
143
  /**
186
- * Returns the native eligibility reference by which an R5 ResearchStudy links
187
- * to its descriptive professional Group. The direction is deliberately from
188
- * `ResearchStudy.recruitment.eligibility` to Group; no invented study field or
189
- * search claim is added to Group.
144
+ * Builds the flat-claims Group POST used to create one enumerated ResearchStudy
145
+ * review team. Every member is added later as its own PractitionerRole claim
146
+ * PATCH. Group remains a description only and cannot carry access policy.
190
147
  *
191
- * @see https://hl7.org/fhir/R5/researchstudy.html
192
148
  * @see https://hl7.org/fhir/R5/group.html
149
+ * @see https://hl7.org/fhir/R5/group-search.html
193
150
  */
194
- export declare function buildVeterinaryResearchStudyEligibilityGroupReference(groupId: string): Readonly<{
195
- reference: `Group/${string}`;
196
- type: 'Group';
197
- }>;
198
- /**
199
- * Builds the native ResearchStudy PATCH that links a descriptive Group through
200
- * `ResearchStudy.recruitment.eligibility`. The existing ResearchStudy batch
201
- * builder wraps this entry; Group and ResearchStudy remain separate resource
202
- * endpoints and no mixed-resource transport envelope is invented.
203
- *
204
- * @see https://hl7.org/fhir/R5/researchstudy.html
205
- * @see https://hl7.org/fhir/R5/researchstudy-search.html
206
- */
207
- export declare function buildVeterinaryResearchStudyEligibilityGroupPatchEntry(input: Readonly<{
151
+ export declare function buildVeterinaryResearchStudyGroupCreateEntry(input: Readonly<{
208
152
  researchStudyId: string;
209
- groupId: string;
210
- }>): VeterinaryResearchStudyBatchEntry;
153
+ group: GroupR5ResearchTeam;
154
+ }>): VeterinaryResearchStudyGroupBatchEntry;
155
+ /** Builds the governed business Identifier; it describes correlation, never authority. */
156
+ export declare function buildVeterinaryResearchStudyReviewTeamIdentifier(researchStudyId: string): Readonly<{
157
+ system: typeof VeterinaryResearchStudyReviewTeamIdentifierSystem;
158
+ value: string;
159
+ }>;
211
160
  /**
212
- * Builds the Group POST used to create one enumerated ResearchStudy review
213
- * team. Every member is a native PractitionerRole reference. Group remains a
214
- * description only and the generated resource cannot carry access policy.
161
+ * Builds the standard FHIR `Group.identifier` token search used to recover the
162
+ * Group after refresh. Re-derivation avoids storing a second linkage and never
163
+ * constitutes access authority.
215
164
  *
216
- * @see https://hl7.org/fhir/R5/group.html
217
165
  * @see https://hl7.org/fhir/R5/group-search.html
218
166
  */
219
- export declare function buildVeterinaryResearchStudyGroupCreateEntry(input: Readonly<{
220
- group: GroupR5ResearchTeam;
221
- }>): VeterinaryResearchStudyGroupBatchEntry;
167
+ export declare function buildVeterinaryResearchStudyReviewTeamGroupSearch(researchStudyId: string): VeterinaryResearchStudyReviewTeamGroupSearch;
222
168
  /**
223
169
  * Builds one independently correlated PATCH per PractitionerRole member. A
224
170
  * PATCH changes descriptive membership only; DCR, Consent and SMART remain
@@ -235,8 +181,9 @@ export declare function buildVeterinaryResearchStudyCreateEntry(input: Readonly<
235
181
  }>): VeterinaryResearchStudyBatchEntry;
236
182
  /**
237
183
  * Builds one independent `_batch` PATCH entry per R5 associatedParty. Keeping
238
- * each complete party in its own entry prevents roles, periods and classifiers
239
- * from being correlated with the wrong PractitionerRole. These entries update
184
+ * each party's claim map in its own entry prevents roles, periods and
185
+ * classifiers from being correlated with the wrong PractitionerRole. Native
186
+ * `associatedParty` JSON is not sent to persistence. These entries update
240
187
  * study membership only; they do not grant CRUDS access.
241
188
  */
242
189
  export declare function buildVeterinaryResearchStudyPartyPatchEntries(input: Readonly<{
@@ -245,17 +192,15 @@ export declare function buildVeterinaryResearchStudyPartyPatchEntries(input: Rea
245
192
  }>): readonly VeterinaryResearchStudyBatchEntry[];
246
193
  /**
247
194
  * Builds the auditable R5 invitation sent to one ResearchStudy associated
248
- * party. The Communication payload is an `application/fhir+json` attachment
249
- * containing a batch Bundle with exactly one `Consent.status = draft`.
195
+ * party. The claims-only Communication carries an `application/vnd.api+json`
196
+ * primary document with exactly one claims-only `Consent.status = draft`.
250
197
  * Draft is descriptive only and can never satisfy authorization. The invitee
251
198
  * must finish DCR and a controller must later persist a separate active
252
199
  * Consent before the study-scoped SMART request can succeed.
253
200
  *
254
- * Native R5 fields retain the exact study boundary in
255
- * `Consent.provision.data.reference` and the affected `ResearchSubject`
256
- * resource family. Project authorization actions remain create/read/update/
257
- * search; their FHIR representation uses the matching REST interaction codes,
258
- * where search is `search-type`. Delete is deliberately absent.
201
+ * The flat Consent claims retain the exact study boundary and affected
202
+ * `ResearchSubject` resource family. Delete is deliberately absent. Native
203
+ * FHIR projection belongs only at an explicit import/export boundary.
259
204
  *
260
205
  * @see https://hl7.org/fhir/R5/communication.html
261
206
  * @see https://hl7.org/fhir/R5/consent.html
@@ -284,6 +229,5 @@ export declare function buildVeterinaryResearchStudyPartyInvitations(input: Read
284
229
  consentId: string;
285
230
  }>[];
286
231
  }>): readonly VeterinaryResearchStudyPartyInvitation[];
287
- /** Decodes the single attached Consent Bundle after validating its boundary. */
288
- export declare function decodeVeterinaryResearchStudyInvitationBundle(communication: VeterinaryResearchStudyPartyInvitation): VeterinaryResearchStudyInvitationBundle;
289
- export {};
232
+ /** Decodes the attached JSON:API Consent document after validating its claims-only boundary. */
233
+ export declare function decodeVeterinaryResearchStudyInvitationDocument(communication: VeterinaryResearchStudyPartyInvitation): VeterinaryResearchStudyInvitationDocument;
@@ -1,12 +1,13 @@
1
- import { normalizeResearchStudyAssociatedParties, projectResearchStudyAssociatedPartyClaims, projectResearchStudyR5SearchClaims, } from 'vet-data-utils-ts/research-study';
1
+ import { normalizeResearchStudyAssociatedParties, normalizeResearchStudyR5FlatClaimsResource, projectResearchStudyAssociatedPartyFlatClaimResources, projectResearchStudyR5FlatClaimsResource, } from 'vet-data-utils-ts/research-study';
2
2
  import { VeterinaryCommunicationPresetFilters, VeterinaryResearchCommunicationTopics, } from 'vet-data-utils-ts/communication';
3
- import { normalizeGroupPractitionerRoleMembers, normalizeGroupR5ResearchTeam, projectGroupR5SearchClaims, } from 'vet-data-utils-ts/group';
3
+ import { normalizeGroupR5FlatClaimsResource, normalizeGroupR5ResearchTeam, projectGroupPractitionerRoleMemberFlatClaimResources, projectGroupR5FlatClaimsResource, } from 'vet-data-utils-ts/group';
4
+ import { HttpMediaTypes } from 'gdc-common-utils-ts/constants/http';
4
5
  import { HealthcareConsentPurposes } from 'gdc-common-utils-ts/constants/healthcare';
5
6
  import { ResourceTypesFhirR5 } from 'gdc-common-utils-ts/constants/fhir-resource-types';
6
7
  import { InteroperableContext } from 'gdc-common-utils-ts/constants/lifecycle';
7
8
  import { ClaimConsent, ConsentDecisions, ConsentStatuses, } from 'gdc-common-utils-ts/models/consent-rule';
8
- const FHIR_RESTFUL_INTERACTION_SYSTEM = 'http://hl7.org/fhir/restful-interaction';
9
- const FHIR_RESOURCE_TYPES_SYSTEM = 'http://hl7.org/fhir/fhir-types';
9
+ import { CommunicationClaim } from 'gdc-common-utils-ts/models/interoperable-claims/communication-claims';
10
+ import { sha1 } from '@noble/hashes/legacy.js';
10
11
  export const VeterinaryResearchStudyPermissionActions = Object.freeze([
11
12
  'create',
12
13
  'read',
@@ -22,6 +23,16 @@ export const VeterinaryResearchStudyInvitationClaimNames = Object.freeze({
22
23
  Action: ClaimConsent.action,
23
24
  ResourceType: ClaimConsent.resourceType,
24
25
  });
26
+ /**
27
+ * Canonical URI Identifier.system linking one descriptive review-team Group
28
+ * to its study through an RFC 9562 UUIDv5 value.
29
+ *
30
+ * @see https://hl7.org/fhir/R5/datatypes.html#Identifier
31
+ * @see https://www.rfc-editor.org/rfc/rfc9562#name-uuid-version-5
32
+ */
33
+ export const VeterinaryResearchStudyReviewTeamIdentifierSystem = 'urn:ietf:rfc:3986';
34
+ /** UUIDv5 namespace governed by this SDK for ResearchStudy review-team identifiers. */
35
+ export const VeterinaryResearchStudyReviewTeamIdentifierNamespace = '6434afe2-d152-5ec2-a88b-ec2a96e0f010';
25
36
  /** Builds the exact GW study-pinned ResearchSubject `crus` capability. */
26
37
  export function buildVeterinaryResearchStudyAccessScope(researchStudyId) {
27
38
  return `organization/ResearchSubject.crus?study=ResearchStudy/${boundedId(researchStudyId)}`;
@@ -69,7 +80,7 @@ export function buildVeterinaryResearchStudyActiveConsentBatch(inputs) {
69
80
  const data = inputs.map(buildVeterinaryResearchStudyActiveConsentEntry);
70
81
  return deepFreeze({ data });
71
82
  }
72
- /** Builds one POST Consent entry accepted by the VET ResearchStudy manager. */
83
+ /** Builds one flat-claims POST Consent entry accepted by the VET ResearchStudy manager. */
73
84
  export function buildVeterinaryResearchStudyActiveConsentEntry(input) {
74
85
  if (input.status !== undefined && input.status !== ConsentStatuses.Active) {
75
86
  throw new TypeError('veterinary_research_study_active_consent_status_invalid');
@@ -88,7 +99,6 @@ export function buildVeterinaryResearchStudyActiveConsentEntry(input) {
88
99
  resource: {
89
100
  resourceType: ResourceTypesFhirR5.Consent,
90
101
  id: consentId,
91
- status: ConsentStatuses.Active,
92
102
  meta: {
93
103
  claims: {
94
104
  '@context': InteroperableContext.FhirApi,
@@ -109,60 +119,45 @@ export function buildVeterinaryResearchStudyActiveConsentEntry(input) {
109
119
  export function buildVeterinaryResearchStudyBatch(entries) {
110
120
  if (!Array.isArray(entries) || entries.length === 0)
111
121
  throw new TypeError('veterinary_research_study_batch_empty');
112
- return deepFreeze({ data: entries.map(entry => clonePlain(entry)) });
122
+ return deepFreeze({ data: entries.map(normalizeResearchStudyBatchEntry) });
113
123
  }
114
124
  /**
115
- * Returns the native eligibility reference by which an R5 ResearchStudy links
116
- * to its descriptive professional Group. The direction is deliberately from
117
- * `ResearchStudy.recruitment.eligibility` to Group; no invented study field or
118
- * search claim is added to Group.
125
+ * Builds the flat-claims Group POST used to create one enumerated ResearchStudy
126
+ * review team. Every member is added later as its own PractitionerRole claim
127
+ * PATCH. Group remains a description only and cannot carry access policy.
119
128
  *
120
- * @see https://hl7.org/fhir/R5/researchstudy.html
121
129
  * @see https://hl7.org/fhir/R5/group.html
130
+ * @see https://hl7.org/fhir/R5/group-search.html
122
131
  */
123
- export function buildVeterinaryResearchStudyEligibilityGroupReference(groupId) {
124
- return deepFreeze({ reference: `Group/${boundedGroupId(groupId)}`, type: 'Group' });
132
+ export function buildVeterinaryResearchStudyGroupCreateEntry(input) {
133
+ const group = normalizedResearchTeamGroup(input.group);
134
+ if (group.member.length > 0)
135
+ throw new TypeError('group_members_require_patch_entries');
136
+ const identifier = buildVeterinaryResearchStudyReviewTeamIdentifier(input.researchStudyId);
137
+ return deepFreeze({
138
+ type: 'Group-v5.0.0',
139
+ resource: projectGroupR5FlatClaimsResource({ ...group, identifier: [identifier] }),
140
+ request: { method: 'POST', url: 'Group' },
141
+ });
125
142
  }
126
- /**
127
- * Builds the native ResearchStudy PATCH that links a descriptive Group through
128
- * `ResearchStudy.recruitment.eligibility`. The existing ResearchStudy batch
129
- * builder wraps this entry; Group and ResearchStudy remain separate resource
130
- * endpoints and no mixed-resource transport envelope is invented.
131
- *
132
- * @see https://hl7.org/fhir/R5/researchstudy.html
133
- * @see https://hl7.org/fhir/R5/researchstudy-search.html
134
- */
135
- export function buildVeterinaryResearchStudyEligibilityGroupPatchEntry(input) {
136
- if (containsAuthorizationProperty(input)) {
137
- throw new TypeError('veterinary_research_study_group_authorization_forbidden');
138
- }
139
- const researchStudyId = boundedId(input.researchStudyId);
140
- const eligibility = buildVeterinaryResearchStudyEligibilityGroupReference(input.groupId);
143
+ /** Builds the governed business Identifier; it describes correlation, never authority. */
144
+ export function buildVeterinaryResearchStudyReviewTeamIdentifier(researchStudyId) {
145
+ const studyUuid = boundedStudyUuid(researchStudyId);
141
146
  return deepFreeze({
142
- type: 'ResearchStudy-v5.0.0',
143
- resource: withProjectedClaims({
144
- resourceType: 'ResearchStudy',
145
- id: researchStudyId,
146
- recruitment: { eligibility },
147
- }),
148
- request: { method: 'PATCH', url: `ResearchStudy/${researchStudyId}` },
147
+ system: VeterinaryResearchStudyReviewTeamIdentifierSystem,
148
+ value: `urn:uuid:${uuidV5(studyUuid, VeterinaryResearchStudyReviewTeamIdentifierNamespace)}`,
149
149
  });
150
150
  }
151
151
  /**
152
- * Builds the Group POST used to create one enumerated ResearchStudy review
153
- * team. Every member is a native PractitionerRole reference. Group remains a
154
- * description only and the generated resource cannot carry access policy.
152
+ * Builds the standard FHIR `Group.identifier` token search used to recover the
153
+ * Group after refresh. Re-derivation avoids storing a second linkage and never
154
+ * constitutes access authority.
155
155
  *
156
- * @see https://hl7.org/fhir/R5/group.html
157
156
  * @see https://hl7.org/fhir/R5/group-search.html
158
157
  */
159
- export function buildVeterinaryResearchStudyGroupCreateEntry(input) {
160
- const group = normalizedResearchTeamGroup(input.group);
161
- return deepFreeze({
162
- type: 'Group-v5.0.0',
163
- resource: withProjectedGroupClaims(group),
164
- request: { method: 'POST', url: 'Group' },
165
- });
158
+ export function buildVeterinaryResearchStudyReviewTeamGroupSearch(researchStudyId) {
159
+ const identifier = buildVeterinaryResearchStudyReviewTeamIdentifier(researchStudyId);
160
+ return deepFreeze({ identifier: `${identifier.system}|${identifier.value}` });
166
161
  }
167
162
  /**
168
163
  * Builds one independently correlated PATCH per PractitionerRole member. A
@@ -171,16 +166,10 @@ export function buildVeterinaryResearchStudyGroupCreateEntry(input) {
171
166
  */
172
167
  export function buildVeterinaryResearchStudyGroupMemberPatchEntries(input) {
173
168
  const id = boundedGroupId(input.groupId);
174
- const members = normalizeGroupPractitionerRoleMembers(input.members);
175
- return deepFreeze(members.map(member => ({
169
+ const resources = projectGroupPractitionerRoleMemberFlatClaimResources({ groupId: id, members: input.members });
170
+ return deepFreeze(resources.map(resource => ({
176
171
  type: 'Group-v5.0.0',
177
- resource: withProjectedGroupClaims(deepFreeze({
178
- resourceType: 'Group',
179
- id,
180
- type: 'practitioner',
181
- membership: 'enumerated',
182
- member: [member],
183
- })),
172
+ resource,
184
173
  request: { method: 'PATCH', url: `Group/${id}` },
185
174
  })));
186
175
  }
@@ -195,42 +184,40 @@ export function buildVeterinaryResearchStudyCreateEntry(input) {
195
184
  const study = normalizedStudy(input.study);
196
185
  return deepFreeze({
197
186
  type: 'ResearchStudy-v5.0.0',
198
- resource: withProjectedClaims(study),
187
+ resource: projectResearchStudyR5FlatClaimsResource(study),
199
188
  request: { method: 'POST', url: 'ResearchStudy' },
200
189
  });
201
190
  }
202
191
  /**
203
192
  * Builds one independent `_batch` PATCH entry per R5 associatedParty. Keeping
204
- * each complete party in its own entry prevents roles, periods and classifiers
205
- * from being correlated with the wrong PractitionerRole. These entries update
193
+ * each party's claim map in its own entry prevents roles, periods and
194
+ * classifiers from being correlated with the wrong PractitionerRole. Native
195
+ * `associatedParty` JSON is not sent to persistence. These entries update
206
196
  * study membership only; they do not grant CRUDS access.
207
197
  */
208
198
  export function buildVeterinaryResearchStudyPartyPatchEntries(input) {
209
199
  const id = boundedId(input.researchStudyId);
210
- const parties = normalizeResearchStudyAssociatedParties(input.associatedParties);
211
- return deepFreeze(parties.map(party => ({
200
+ const resources = projectResearchStudyAssociatedPartyFlatClaimResources({
201
+ researchStudyId: id,
202
+ associatedParties: input.associatedParties,
203
+ });
204
+ return deepFreeze(resources.map(resource => ({
212
205
  type: 'ResearchStudy-v5.0.0',
213
- resource: withProjectedClaims({
214
- resourceType: 'ResearchStudy',
215
- id,
216
- associatedParty: [party],
217
- }),
206
+ resource,
218
207
  request: { method: 'PATCH', url: `ResearchStudy/${id}` },
219
208
  })));
220
209
  }
221
210
  /**
222
211
  * Builds the auditable R5 invitation sent to one ResearchStudy associated
223
- * party. The Communication payload is an `application/fhir+json` attachment
224
- * containing a batch Bundle with exactly one `Consent.status = draft`.
212
+ * party. The claims-only Communication carries an `application/vnd.api+json`
213
+ * primary document with exactly one claims-only `Consent.status = draft`.
225
214
  * Draft is descriptive only and can never satisfy authorization. The invitee
226
215
  * must finish DCR and a controller must later persist a separate active
227
216
  * Consent before the study-scoped SMART request can succeed.
228
217
  *
229
- * Native R5 fields retain the exact study boundary in
230
- * `Consent.provision.data.reference` and the affected `ResearchSubject`
231
- * resource family. Project authorization actions remain create/read/update/
232
- * search; their FHIR representation uses the matching REST interaction codes,
233
- * where search is `search-type`. Delete is deliberately absent.
218
+ * The flat Consent claims retain the exact study boundary and affected
219
+ * `ResearchSubject` resource family. Delete is deliberately absent. Native
220
+ * FHIR projection belongs only at an explicit import/export boundary.
234
221
  *
235
222
  * @see https://hl7.org/fhir/R5/communication.html
236
223
  * @see https://hl7.org/fhir/R5/consent.html
@@ -256,66 +243,42 @@ export function buildVeterinaryResearchStudyPartyInvitation(input) {
256
243
  if (!topic)
257
244
  throw new TypeError('veterinary_research_study_invitation_topic_unavailable');
258
245
  const consent = {
259
- resourceType: 'Consent',
246
+ type: 'Consent-v5.0.0',
260
247
  id: consentId,
261
- status: 'draft',
262
- grantor: [{ reference: controllerReference, type: 'Organization' }],
263
- controller: [{ reference: controllerReference, type: 'Organization' }],
264
- grantee: [{ reference: partyReference, type: 'PractitionerRole' }],
265
- decision: 'permit',
266
- provision: [{
267
- actor: [{ reference: { reference: partyReference, type: 'PractitionerRole' } }],
268
- action: VeterinaryResearchStudyPermissionActions.map(action => ({
269
- coding: [{
270
- system: FHIR_RESTFUL_INTERACTION_SYSTEM,
271
- code: action === 'search' ? 'search-type' : action,
272
- }],
273
- })),
274
- purpose: [{ system: topic.system, code: topic.code }],
275
- resourceType: [{ system: FHIR_RESOURCE_TYPES_SYSTEM, code: 'ResearchSubject' }],
276
- data: [{ meaning: 'related', reference: { reference: researchStudyReference, type: 'ResearchStudy' } }],
277
- }],
278
- meta: {
279
- claims: {
280
- [VeterinaryResearchStudyInvitationClaimNames.Context]: 'org.hl7.fhir.api',
281
- [VeterinaryResearchStudyInvitationClaimNames.Status]: 'draft',
282
- [VeterinaryResearchStudyInvitationClaimNames.Decision]: 'permit',
283
- [VeterinaryResearchStudyInvitationClaimNames.ActorIdentifier]: partyReference,
284
- [VeterinaryResearchStudyInvitationClaimNames.Purpose]: topic.value,
285
- [VeterinaryResearchStudyInvitationClaimNames.Action]: VeterinaryResearchStudyPermissionActions.join(','),
286
- [VeterinaryResearchStudyInvitationClaimNames.ResourceType]: 'ResearchSubject',
287
- },
248
+ attributes: {
249
+ [VeterinaryResearchStudyInvitationClaimNames.Context]: InteroperableContext.FhirApi,
250
+ [VeterinaryResearchStudyInvitationClaimNames.Status]: ConsentStatuses.Draft,
251
+ [VeterinaryResearchStudyInvitationClaimNames.Decision]: ConsentDecisions.Permit,
252
+ [VeterinaryResearchStudyInvitationClaimNames.ActorIdentifier]: partyReference,
253
+ [ClaimConsent.grantee]: partyReference,
254
+ [VeterinaryResearchStudyInvitationClaimNames.Purpose]: topic.value,
255
+ [VeterinaryResearchStudyInvitationClaimNames.Action]: VeterinaryResearchStudyPermissionActions.join(','),
256
+ [VeterinaryResearchStudyInvitationClaimNames.ResourceType]: 'ResearchSubject',
257
+ [ClaimConsent.sourceReference]: researchStudyReference,
288
258
  },
289
259
  };
290
- const bundle = {
291
- resourceType: 'Bundle',
292
- type: 'batch',
293
- entry: [{
294
- fullUrl: `urn:uuid:${consentId}`,
295
- resource: consent,
296
- request: { method: 'POST', url: 'Consent' },
297
- }],
260
+ const document = {
261
+ data: [consent],
298
262
  };
299
263
  const category = VeterinaryCommunicationPresetFilters.ResearchAgreements.categories[0];
300
- const [categorySystem, categoryCode] = splitCodingToken(category);
301
264
  const sent = optionalInstant(input.sentAt);
302
265
  return deepFreeze({
303
266
  resourceType: 'Communication',
304
267
  id: communicationId,
305
- status: 'completed',
306
- category: [{ coding: [{ system: categorySystem, code: categoryCode }] }],
307
- topic: { coding: [{ system: topic.system, code: topic.code }] },
308
- about: [{ reference: researchStudyReference, type: 'ResearchStudy' }],
309
- recipient: [{ reference: partyReference, type: 'PractitionerRole' }],
310
- sender: { reference: controllerReference, type: 'Organization' },
311
- ...(sent ? { sent } : {}),
312
- payload: [{
313
- contentAttachment: {
314
- contentType: 'application/fhir+json',
315
- title: 'research-study-draft-consent.json',
316
- data: encodeJsonBase64(bundle),
317
- },
318
- }],
268
+ meta: { claims: {
269
+ '@context': InteroperableContext.FhirApi,
270
+ [CommunicationClaim.Identifier]: communicationId,
271
+ [CommunicationClaim.Status]: 'completed',
272
+ [CommunicationClaim.Category]: category,
273
+ [CommunicationClaim.Topic]: topic.value,
274
+ [CommunicationClaim.ContentReference]: researchStudyReference,
275
+ [CommunicationClaim.Recipient]: partyReference,
276
+ [CommunicationClaim.Sender]: controllerReference,
277
+ ...(sent ? { [CommunicationClaim.Sent]: sent } : {}),
278
+ [CommunicationClaim.ContentAttachmentType]: HttpMediaTypes.JsonApi,
279
+ [CommunicationClaim.ContentAttachmentTitle]: 'research-study-draft-consent.json',
280
+ [CommunicationClaim.ContentAttachmentData]: encodeJsonBase64(document),
281
+ } },
319
282
  });
320
283
  }
321
284
  /**
@@ -334,41 +297,50 @@ export function buildVeterinaryResearchStudyPartyInvitations(input) {
334
297
  ...invitation,
335
298
  })));
336
299
  }
337
- /** Decodes the single attached Consent Bundle after validating its boundary. */
338
- export function decodeVeterinaryResearchStudyInvitationBundle(communication) {
339
- const attachment = communication?.payload?.[0]?.contentAttachment;
340
- if (communication?.resourceType !== 'Communication'
341
- || attachment?.contentType !== 'application/fhir+json'
342
- || !attachment.data) {
343
- throw new TypeError('veterinary_research_study_invitation_bundle_invalid');
300
+ /** Decodes the attached JSON:API Consent document after validating its claims-only boundary. */
301
+ export function decodeVeterinaryResearchStudyInvitationDocument(communication) {
302
+ if (!isClaimsOnlyResource(communication, 'Communication')) {
303
+ throw new TypeError('veterinary_research_study_invitation_document_invalid');
344
304
  }
345
- const decoded = JSON.parse(decodeJsonBase64(attachment.data));
346
- if (decoded?.resourceType !== 'Bundle'
347
- || decoded.type !== 'batch'
348
- || decoded.entry?.length !== 1
349
- || decoded.entry[0]?.resource?.resourceType !== 'Consent'
350
- || decoded.entry[0].resource.status !== 'draft') {
351
- throw new TypeError('veterinary_research_study_invitation_bundle_invalid');
305
+ const claims = communication.meta.claims;
306
+ const encoded = claims[CommunicationClaim.ContentAttachmentData];
307
+ if (claims[CommunicationClaim.ContentAttachmentType] !== HttpMediaTypes.JsonApi || !encoded) {
308
+ throw new TypeError('veterinary_research_study_invitation_document_invalid');
309
+ }
310
+ let decoded;
311
+ try {
312
+ decoded = JSON.parse(decodeJsonBase64(encoded));
313
+ }
314
+ catch {
315
+ throw new TypeError('veterinary_research_study_invitation_document_invalid');
316
+ }
317
+ const entry = decoded?.data?.[0];
318
+ if (decoded?.data?.length !== 1
319
+ || entry?.type !== 'Consent-v5.0.0'
320
+ || Object.keys(entry).some(key => !['type', 'id', 'attributes'].includes(key))
321
+ || !boundedJsonApiId(entry.id)
322
+ || !isFlatStringAttributes(entry.attributes)
323
+ || !isValidInvitationDraftConsentAttributes(entry.attributes, claims)) {
324
+ throw new TypeError('veterinary_research_study_invitation_document_invalid');
352
325
  }
353
326
  return deepFreeze(decoded);
354
327
  }
355
328
  function normalizedStudy(input) {
356
329
  if (!input || input.resourceType !== 'ResearchStudy')
357
330
  throw new TypeError('veterinary_research_study_invalid');
331
+ if (input.associatedParty !== undefined) {
332
+ throw new TypeError('veterinary_research_study_parties_require_patch_entries');
333
+ }
358
334
  const source = clonePlain(input);
359
335
  const id = boundedId(input.id);
360
336
  const status = String(input.status || '');
361
337
  if (!['draft', 'active', 'retired', 'unknown'].includes(status)) {
362
338
  throw new TypeError('veterinary_research_study_status_invalid');
363
339
  }
364
- const associatedParty = input.associatedParty === undefined
365
- ? undefined
366
- : normalizeResearchStudyAssociatedParties(input.associatedParty);
367
340
  return deepFreeze({
368
341
  ...source,
369
342
  id,
370
343
  status: status,
371
- ...(associatedParty ? { associatedParty } : {}),
372
344
  });
373
345
  }
374
346
  function normalizedResearchTeamGroup(input) {
@@ -380,28 +352,24 @@ function normalizeResearchStudyGroupEntry(input) {
380
352
  if (!input || input.type !== 'Group-v5.0.0') {
381
353
  throw new TypeError('veterinary_research_study_group_entry_invalid');
382
354
  }
383
- const group = normalizedResearchTeamGroup(input.resource);
355
+ let resource;
356
+ try {
357
+ resource = normalizeGroupR5FlatClaimsResource(input.resource);
358
+ }
359
+ catch {
360
+ throw new TypeError('veterinary_research_study_group_entry_invalid');
361
+ }
384
362
  const method = input.request?.method;
385
- const expectedUrl = method === 'POST' ? 'Group' : method === 'PATCH' ? `Group/${group.id}` : '';
363
+ const expectedUrl = method === 'POST' ? 'Group' : method === 'PATCH' ? `Group/${resource.id}` : '';
386
364
  if (!expectedUrl || input.request.url !== expectedUrl) {
387
365
  throw new TypeError('veterinary_research_study_group_request_invalid');
388
366
  }
389
367
  return deepFreeze({
390
368
  type: 'Group-v5.0.0',
391
- resource: withProjectedGroupClaims(group),
369
+ resource,
392
370
  request: { method, url: expectedUrl },
393
371
  });
394
372
  }
395
- function withProjectedGroupClaims(group) {
396
- const claims = projectGroupR5SearchClaims(group);
397
- return deepFreeze({
398
- ...group,
399
- meta: {
400
- ...(typeof group.meta === 'object' && group.meta !== null ? group.meta : {}),
401
- claims,
402
- },
403
- });
404
- }
405
373
  function clonePlain(value) {
406
374
  if (Array.isArray(value))
407
375
  return value.map(clonePlain);
@@ -410,33 +378,66 @@ function clonePlain(value) {
410
378
  }
411
379
  return value;
412
380
  }
413
- function withProjectedClaims(study) {
414
- const standardClaims = projectResearchStudyR5SearchClaims(study);
415
- const partyClaims = aggregatePartyClaims(study.associatedParty || []);
381
+ function normalizeResearchStudyBatchEntry(input) {
382
+ if (!input || input.type !== 'ResearchStudy-v5.0.0') {
383
+ throw new TypeError('veterinary_research_study_entry_invalid');
384
+ }
385
+ let resource;
386
+ try {
387
+ resource = normalizeResearchStudyR5FlatClaimsResource(input.resource);
388
+ }
389
+ catch {
390
+ throw new TypeError('veterinary_research_study_entry_invalid');
391
+ }
392
+ const method = input.request?.method;
393
+ const expectedUrl = method === 'POST'
394
+ ? 'ResearchStudy'
395
+ : method === 'PATCH'
396
+ ? `ResearchStudy/${resource.id}`
397
+ : '';
398
+ if (!expectedUrl || input.request.url !== expectedUrl) {
399
+ throw new TypeError('veterinary_research_study_request_invalid');
400
+ }
416
401
  return deepFreeze({
417
- ...study,
418
- meta: {
419
- ...(study.meta || {}),
420
- claims: { ...standardClaims, ...partyClaims },
421
- },
402
+ type: 'ResearchStudy-v5.0.0',
403
+ resource,
404
+ request: { method, url: expectedUrl },
422
405
  });
423
406
  }
424
- function aggregatePartyClaims(parties) {
425
- const output = {};
426
- for (const projection of projectResearchStudyAssociatedPartyClaims(parties)) {
427
- for (const [name, values] of Object.entries(projection.claims)) {
428
- if (values.length > 0)
429
- (output[name] || (output[name] = [])).push(...values);
430
- }
431
- }
432
- return Object.fromEntries(Object.entries(output).map(([name, values]) => [name, Object.freeze([...values])]));
433
- }
434
407
  function boundedId(value) {
435
408
  const id = String(value || '').trim();
436
409
  if (!/^[A-Za-z0-9\-.]{1,64}$/.test(id))
437
410
  throw new TypeError('veterinary_research_study_id_invalid');
438
411
  return id;
439
412
  }
413
+ function boundedStudyUuid(value) {
414
+ const uuid = String(value || '').trim().toLowerCase();
415
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(uuid)) {
416
+ throw new TypeError('veterinary_research_study_review_team_study_id_invalid');
417
+ }
418
+ return uuid;
419
+ }
420
+ function uuidV5(name, namespace) {
421
+ const namespaceHexBytes = namespace.replace(/-/g, '').match(/.{2}/g) || [];
422
+ const namespaceBytes = Uint8Array.from(namespaceHexBytes.map(byte => Number.parseInt(byte, 16)));
423
+ const nameBytes = new TextEncoder().encode(name);
424
+ const input = new Uint8Array(namespaceBytes.length + nameBytes.length);
425
+ input.set(namespaceBytes);
426
+ input.set(nameBytes, namespaceBytes.length);
427
+ const digest = sha1(input).slice(0, 16);
428
+ digest[6] = (digest[6] & 0x0f) | 0x50;
429
+ digest[8] = (digest[8] & 0x3f) | 0x80;
430
+ const hex = Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('');
431
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
432
+ }
433
+ function boundedJsonApiId(value) {
434
+ try {
435
+ return boundedId(value);
436
+ }
437
+ catch {
438
+ return undefined;
439
+ }
440
+ }
440
441
  function boundedGroupId(value) {
441
442
  const id = String(value || '').trim();
442
443
  if (!/^[A-Za-z0-9\-.]{1,64}$/.test(id))
@@ -470,23 +471,6 @@ function optionalInstant(value) {
470
471
  throw new TypeError('veterinary_research_study_invitation_sent_invalid');
471
472
  return instant;
472
473
  }
473
- function containsAuthorizationProperty(value, seen = new WeakSet()) {
474
- if (!value || typeof value !== 'object')
475
- return false;
476
- if (seen.has(value))
477
- return false;
478
- seen.add(value);
479
- if (Array.isArray(value))
480
- return value.some(item => containsAuthorizationProperty(item, seen));
481
- return Object.entries(value).some(([name, nested]) => ['authorization', 'permission', 'scope'].includes(name) || containsAuthorizationProperty(nested, seen));
482
- }
483
- function splitCodingToken(value) {
484
- const separator = value.lastIndexOf('|');
485
- if (separator < 1 || separator === value.length - 1) {
486
- throw new TypeError('veterinary_research_study_invitation_category_invalid');
487
- }
488
- return [value.slice(0, separator), value.slice(separator + 1)];
489
- }
490
474
  function encodeJsonBase64(value) {
491
475
  const bytes = new TextEncoder().encode(JSON.stringify(value));
492
476
  let binary = '';
@@ -507,3 +491,36 @@ function deepFreeze(value, seen = new WeakSet()) {
507
491
  deepFreeze(nested, seen);
508
492
  return Object.freeze(value);
509
493
  }
494
+ function isRecord(value) {
495
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
496
+ }
497
+ function isClaimsOnlyResource(value, resourceType) {
498
+ if (!isRecord(value) || value.resourceType !== resourceType)
499
+ return false;
500
+ if (Object.keys(value).some(key => !['resourceType', 'id', 'meta'].includes(key)))
501
+ return false;
502
+ if (typeof value.id !== 'string' || !isRecord(value.meta) || !isRecord(value.meta.claims))
503
+ return false;
504
+ return Object.values(value.meta.claims).every(claim => typeof claim === 'string' && claim.trim().length > 0);
505
+ }
506
+ function isFlatStringAttributes(value) {
507
+ return isRecord(value)
508
+ && Object.keys(value).length > 0
509
+ && Object.values(value).every(attribute => typeof attribute === 'string' && attribute.trim().length > 0);
510
+ }
511
+ function isValidInvitationDraftConsentAttributes(attributes, communicationClaims) {
512
+ const expected = {
513
+ [VeterinaryResearchStudyInvitationClaimNames.Context]: InteroperableContext.FhirApi,
514
+ [VeterinaryResearchStudyInvitationClaimNames.Status]: ConsentStatuses.Draft,
515
+ [VeterinaryResearchStudyInvitationClaimNames.Decision]: ConsentDecisions.Permit,
516
+ [VeterinaryResearchStudyInvitationClaimNames.ActorIdentifier]: communicationClaims[CommunicationClaim.Recipient],
517
+ [ClaimConsent.grantee]: communicationClaims[CommunicationClaim.Recipient],
518
+ [VeterinaryResearchStudyInvitationClaimNames.Purpose]: communicationClaims[CommunicationClaim.Topic],
519
+ [VeterinaryResearchStudyInvitationClaimNames.Action]: VeterinaryResearchStudyPermissionActions.join(','),
520
+ [VeterinaryResearchStudyInvitationClaimNames.ResourceType]: 'ResearchSubject',
521
+ [ClaimConsent.sourceReference]: communicationClaims[CommunicationClaim.ContentReference],
522
+ };
523
+ const expectedEntries = Object.entries(expected);
524
+ return Object.keys(attributes).length === expectedEntries.length
525
+ && expectedEntries.every(([name, value]) => Boolean(value) && attributes[name] === value);
526
+ }
@@ -4,8 +4,9 @@ This SDK separates study membership, an invitation, and authorization. They
4
4
  are three different facts:
5
5
 
6
6
  1. `ResearchStudy.associatedParty` records who is expected to collaborate.
7
- 2. One R5 `Communication` invites that exact `PractitionerRole` and carries a
8
- batch Bundle with one `Consent.status = draft`.
7
+ 2. One claims-only `Communication` invites that exact `PractitionerRole` and
8
+ carries a JSON:API primary document with one claims-only
9
+ `Consent.status = draft`.
9
10
  3. Only after the professional completes DCR and the controller persists a
10
11
  later `Consent.status = active` may the professional request the
11
12
  study-scoped SMART authorization.
@@ -17,8 +18,6 @@ related to the exact ResearchStudy. Whole-twin delete is absent.
17
18
  ```ts
18
19
  import {
19
20
  buildVeterinaryResearchStudyActiveConsentBatch,
20
- buildVeterinaryResearchStudyBatch,
21
- buildVeterinaryResearchStudyEligibilityGroupPatchEntry,
22
21
  buildVeterinaryResearchStudyGroupBatch,
23
22
  buildVeterinaryResearchStudyGroupCreateEntry,
24
23
  buildVeterinaryResearchStudyGroupMemberPatchEntries,
@@ -28,6 +27,7 @@ import {
28
27
 
29
28
  const researchGroupId = 'review-team'
30
29
  const groupCreate = buildVeterinaryResearchStudyGroupCreateEntry({
30
+ researchStudyId: study.id,
31
31
  group: {
32
32
  resourceType: 'Group',
33
33
  id: researchGroupId,
@@ -42,13 +42,6 @@ const memberPatches = buildVeterinaryResearchStudyGroupMemberPatchEntries({
42
42
  })
43
43
  const groupBatch = buildVeterinaryResearchStudyGroupBatch([groupCreate, ...memberPatches])
44
44
  // The protected controller BFF submits `groupBatch`; membership grants no access.
45
- const eligibilityPatch = buildVeterinaryResearchStudyEligibilityGroupPatchEntry({
46
- researchStudyId: study.id,
47
- groupId: researchGroupId,
48
- })
49
- const studyPatchBatch = buildVeterinaryResearchStudyBatch([eligibilityPatch])
50
- // Submit `studyPatchBatch` through the protected ResearchStudy BFF after the
51
- // Group exists. The two native resource endpoints remain separate.
52
45
 
53
46
  const invitations = buildVeterinaryResearchStudyPartyInvitations({
54
47
  researchStudyId: study.id,
@@ -89,14 +82,28 @@ const authorization = buildVeterinaryResearchStudySmartAuthorization({
89
82
  await professionalRuntime.requestSmartToken(authorization)
90
83
  ```
91
84
 
92
- The controller may also create a descriptive review team with
85
+ The controller may independently create a descriptive review team with
93
86
  `buildVeterinaryResearchStudyGroupCreateEntry()` and append each registered
94
87
  `PractitionerRole` with
95
- `buildVeterinaryResearchStudyGroupMemberPatchEntries()`. The Group is linked
96
- from `ResearchStudy.recruitment.eligibility` using
97
- `buildVeterinaryResearchStudyEligibilityGroupPatchEntry()` and the existing
98
- ResearchStudy batch builder. Group membership is not evidence of DCR or
99
- Consent and never grants the SMART authorization above.
88
+ `buildVeterinaryResearchStudyGroupMemberPatchEntries()`. Individual
89
+ professionals are represented in the study by their separate
90
+ `ResearchStudy.associatedParty` PATCH entries. The professional Group is not
91
+ `ResearchStudy.recruitment.eligibility`: that FHIR element describes the
92
+ research-subject cohort or its eligibility criteria. Group membership is not
93
+ evidence of DCR or Consent and never grants the SMART authorization above.
94
+
95
+ GW persistence entries for ResearchStudy, professional Group and active
96
+ Consent contain only `resourceType`, `id` and `resource.meta.claims`. Their
97
+ FHIR-like field names are flat claims. Native nested FHIR JSON is reserved for
98
+ an explicit export/projection boundary, not these GW builders.
99
+
100
+ The draft Consent inside the Communication attachment is an explicit JSON:API
101
+ 1.x `application/vnd.api+json` proposal for recipient review. Its `data[]`
102
+ resource object contains only `type`, `id` and `attributes`; those attributes
103
+ are the flat Consent claims. It has no internal `resource`, `request`, FHIR
104
+ Bundle or Bundle entry. The outer Communication remains claims-only. The draft
105
+ never authorizes access; only the later flat-claims active Consent POST is
106
+ evaluated by GW.
100
107
 
101
108
  These builders follow the native [FHIR R5 Group resource](https://hl7.org/fhir/R5/group.html)
102
109
  and its exact [resource-specific search catalogue](https://hl7.org/fhir/R5/group-search.html).
@@ -107,17 +114,26 @@ the authorization subject `ResearchStudy/<id>` from the exact unescaped scope.
107
114
  Percent-encoding the `/` in that scope would no longer match the active
108
115
  Consent action and is rejected.
109
116
 
110
- The Bundle is encoded as the standard
111
- `Communication.payload.contentAttachment` with
112
- `contentType = application/fhir+json`. The Consent uses:
117
+ The JSON:API primary document is base64 encoded in canonical
118
+ `Communication.content-attachment-data`, paired with
119
+ `Communication.content-attachment-type = application/vnd.api+json`. The
120
+ Consent claims use:
113
121
 
114
- - `Consent.grantee` and `Consent.provision.actor` for the invited
122
+ - `Consent.grantee` and `Consent.actor-identifier` for the invited
115
123
  `PractitionerRole`;
116
- - `Consent.grantor` and `Consent.controller` for the controller organization;
117
- - `Consent.provision.purpose` with HL7 v3 ActReason `HRESCH`;
118
- - `Consent.provision.resourceType` for `ResearchSubject`;
119
- - `Consent.provision.data.reference` for the exact `ResearchStudy/{id}`;
120
- - FHIR REST interaction codes `create`, `read`, `update`, and `search-type`.
124
+ - outer `Communication.sender` for the controller organization;
125
+ - `Consent.purpose` with HL7 v3 ActReason `HRESCH`;
126
+ - `Consent.resource-type` for `ResearchSubject`;
127
+ - `Consent.source-reference` for the exact `ResearchStudy/{id}`;
128
+ - `Consent.action` for `create`, `read`, `update`, and `search`.
129
+
130
+ The Group POST adds a governed business `Group.identifier` derived from the
131
+ ResearchStudy UUID. Use
132
+ `buildVeterinaryResearchStudyReviewTeamGroupSearch(study.id)` to recover the
133
+ same descriptive review-team Group after a refresh. The helper deterministically
134
+ re-derives a UUIDv5 using the SDK-exported, governed namespace and emits
135
+ `urn:ietf:rfc:3986|urn:uuid:<uuidv5>`. This one-way correlation is a standard
136
+ Group identifier token, not eligibility, Consent, permission or SMART authority.
121
137
 
122
138
  FHIR R5 references:
123
139
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vet-sdk-core-ts",
3
- "version": "0.4.13",
3
+ "version": "0.4.15",
4
4
  "description": "Browser-safe VetChain core contracts and governed animal species identifiers",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Connecting Solution & Applications Ltd",
@@ -64,6 +64,6 @@
64
64
  "dependencies": {
65
65
  "@noble/hashes": "^2.2.0",
66
66
  "gdc-common-utils-ts": "2.9.4",
67
- "vet-data-utils-ts": "0.5.1"
67
+ "vet-data-utils-ts": "0.5.2"
68
68
  }
69
69
  }