@choiceapps/communityservices-sdk 0.8.1 → 0.9.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/API.md CHANGED
@@ -107,7 +107,10 @@ A preflight refusal after enrollment creation can identify `step: "scalars"` whi
107
107
  | `ADOPTED_ENROLLMENT_IDENTITY_OR_CONFIGURATION_CHANGED` | adopted enrollment identity or configuration changed |
108
108
  | `ADOPTION_COMPLETE_COHORT_REQUIRED` | adoption requires the complete linked enrollment cohort |
109
109
  | `ADOPTION_ENROLLMENT_CONTEXT_MISMATCH` | adoption enrollment context mismatch |
110
+ | `ADOPTION_ENROLLMENT_GROUP_MISMATCH` | adoption enrollment group changed<br>adoption enrollment group mismatch |
111
+ | `ADOPTION_ENROLLMENT_HOUSEHOLD_MISMATCH` | adoption enrollment household mismatch<br>adoption household differs from enrollment association |
110
112
  | `ADOPTION_EXIT_PRECEDES_ENTRY` | adoption exit precedes entry |
113
+ | `ADOPTION_HEAD_EVIDENCE_REQUIRED` | adoption head evidence required<br>native adoption requires original entry head relationship evidence |
111
114
  | `ADOPTION_HOUSEHOLD_HEAD_MISMATCH` | adoption household head mismatch |
112
115
  | `ADOPTION_HOUSEHOLD_MEMBERSHIP_MISMATCH` | adoption household membership mismatch |
113
116
  | `ADOPTION_HOUSEHOLD_REQUIRED` | linked household enrollment requires adoptHouseholdEnrollment |
@@ -200,12 +203,13 @@ A preflight refusal after enrollment creation can identify `step: "scalars"` whi
200
203
  | `HEAD_OF_HOUSEHOLD_CHANGE_IS_A_RED_FLAG_USE_A_SEPARATE_RETROACTIVE_CORRECTION_FOR_A_MEMBER_WITH` | head of household change is a red flag; use a separate retroactive correction for a member with the same first entry date, or exit all enrollments and create a fresh enrollment |
201
204
  | `HOUSEHOLD_API_AND_AUTHORITATIVE_ENROLLMENT_SESSION_REQUIRED` | household API and authoritative enrollment session required |
202
205
  | `HOUSEHOLD_DISCOVERY_AMBIGUOUS` | a unique current household matching the full cohort, head and dates is required |
203
- | `HOUSEHOLD_DISCOVERY_COHORT_CHANGED` | household membership changed during discovery<br>linked enrollment cohort changed during discovery |
206
+ | `HOUSEHOLD_DISCOVERY_COHORT_CHANGED` | household membership changed during discovery<br>linked enrollment cohort changed during discovery<br>native household or enrollment cohort changed during discovery<br>native household or enrollment group changed during discovery |
204
207
  | `HOUSEHOLD_DISCOVERY_DETAIL_INVALID` | complete linked enrollment cohort required<br>group flag conflicts with linked cohort |
205
208
  | `HOUSEHOLD_DISCOVERY_INPUT_INVALID` | head client and enrollment IDs required |
206
209
  | `HOUSEHOLD_DISCOVERY_LIST_INCOMPLETE` | household list count unavailable or changed<br>incomplete household list<br>invalid household list |
210
+ | `HOUSEHOLD_DISCOVERY_NATIVE_LINK_REQUIRED` | native household and enrollment group IDs required |
207
211
  | `HOUSEHOLD_DISCOVERY_NOT_FOUND` | a unique current household matching the full cohort, head and dates is required |
208
- | `HOUSEHOLD_DISCOVERY_READERS_REQUIRED` | GWT detail and complete household readers required |
212
+ | `HOUSEHOLD_DISCOVERY_READERS_REQUIRED` | authoritative enrollment reader required<br>legacy readers require complete household readers |
209
213
  | `HOUSEHOLD_ENROLLMENT_CONTEXT_MISMATCH` | household enrollment context mismatch |
210
214
  | `HOUSEHOLD_ENROLLMENT_IDENTITY_CHANGED` | household enrollment identity changed |
211
215
  | `HOUSEHOLD_ENROLLMENT_MEMBERS_CHANGED` | household enrollment members changed |
@@ -214,6 +218,7 @@ A preflight refusal after enrollment creation can identify `step: "scalars"` whi
214
218
  | `HOUSEHOLD_KEY_CONFLICTS_WITH_PREVIOUS_INTENT` | household key conflicts with previous intent |
215
219
  | `HOUSEHOLD_MEMBERS_UNAVAILABLE` | household members unavailable |
216
220
  | `HOUSEHOLD_MEMBERSHIP_CHANGED_WHILE_ACQUIRING_LOCKS_RETRY` | household membership changed while acquiring locks; retry |
221
+ | `HOUSEHOLD_NATIVE_LINK_CHANGED` | household native link changed<br>native enrollment household or group changed |
217
222
  | `HOUSEHOLD_ORIGINAL_HEAD_AND_COMPLETE_ENROLLMENT_COHORT_REQUIRED` | household, original head and complete enrollment cohort required |
218
223
  | `HOUSEHOLD_PROGRAM_AND_SUPPORTED_ENTRY_EXIT_TYPE_REQUIRED` | household, program and supported Entry/Exit type required |
219
224
  | `HOUSEHOLD_REVIEW_IDENTITY_CHANGED` | household review identity changed |
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.1 - 2026-11-11
4
+
5
+ - Keep the pinned Chromium download and full browser checks on GitHub Ubuntu 24.04 runners while using their preinstalled runtime libraries. Avoid the redundant APT/font installation that blocked 0.9.0 public publication when the Ubuntu mirror failed DNS resolution. SDK runtime behavior is unchanged from 0.9.0.
6
+
7
+ ## 0.9.0 - 2026-11-11
8
+
9
+ - Expose native `householdId` on managed GWT enrollment list/detail reads, separately from `groupId`.
10
+ - Discover and adopt linked enrollments from the head's IDs using the recorded household association, with cohort-wide consistency and original-head checks. Explicit household IDs must match the enrollment record.
11
+ - Use pinned native household/group identity for existing-group interims and exits, allowing changed current membership while preserving original enrollment-head coverage. Creation and additions still validate current membership.
12
+ - Preserve existing adoption keys and legacy custom-reader behavior; discovery reports whether evidence is `native-enrollment` or `verified-membership`.
13
+ - Verify native reads on existing training Coordinated Entry groups and read-only adoption of open/exited groups with no-write retries. No live enrollment or assessment records were changed.
14
+
3
15
  ## 0.8.1 - 2026-11-11
4
16
 
5
17
  - Recheck mobile scalar collisions immediately before the first scalar-stage attempt, including answers that appeared after enrollment or review creation. Refuse known collisions and unavailable evidence without sending the batch or marking that stage attempted; preserve earlier lifecycle writes and prior-attempt reconciliation.
@@ -110,10 +110,9 @@ export interface AdoptHouseholdEnrollmentInput {
110
110
  members:Array<{clientId:number;enrollmentId:number}>;
111
111
  }
112
112
  export interface HouseholdEnrollmentDiscoveryInput {headClientId:number;headEnrollmentId:number;}
113
- export interface HouseholdEnrollmentDiscoveryResult extends HouseholdEnrollmentDiscoveryInput {
114
- householdId:number;householdSource:'verified-membership';providerId:number;type:string;
115
- members:Array<{clientId:number;enrollmentId:number}>;
116
- }
113
+ export type HouseholdEnrollmentDiscoveryResult = HouseholdEnrollmentDiscoveryInput & {
114
+ householdId:number;providerId:number;type:string;members:Array<{clientId:number;enrollmentId:number}>;
115
+ } & ({householdSource:'native-enrollment';groupId:number|string}|{householdSource:'verified-membership';groupId?:never});
117
116
  export interface AdoptHouseholdEnrollmentFromHeadInput extends HouseholdEnrollmentDiscoveryInput {key:string;}
118
117
  export interface RegisterEntryRowsInput {
119
118
  key:string;enrollmentKey:string;clientId:number;
@@ -220,6 +219,8 @@ export interface HouseholdExitInput extends HouseholdExitDetailInput {
220
219
  members:HouseholdMemberPlan[];
221
220
  }
222
221
  export interface HouseholdEnrollmentResult {
222
+ groupId?:number|string;
223
+ householdSource?:'native-enrollment';
223
224
  key:string;
224
225
  outcome:'verified'|'already-present';
225
226
  householdId:number;
@@ -229,6 +230,8 @@ export interface HouseholdEnrollmentResult {
229
230
  export interface HouseholdInterimResult extends Omit<HouseholdEnrollmentResult,'members'> {members:InterimWorkflowResult[];}
230
231
  export interface HouseholdExitResult extends Omit<HouseholdEnrollmentResult,'members'> {members:ExitWorkflowResult[];}
231
232
  export interface HouseholdWorkflowStatus {
233
+ groupId?:number|string;
234
+ householdSource?:'native-enrollment';
232
235
  key:string;
233
236
  kind:'entry'|'addition'|'interim'|'exit';
234
237
  stage:'pending'|'verified';
@@ -246,7 +249,7 @@ export interface EnrollmentWorkflowOptions {
246
249
  store:UiMutationStore;
247
250
  /** Required for SVPPROFDOB answers or carry-forward. Share this workflow's durable store. */
248
251
  birthDates?:DateOfBirth;
249
- /** Required for exits; also required with api.households and helpers.addHouseholdMembers for household operations. */
252
+ /** Required for exits and household operations. Household creation/additions also need api.households and helpers.addHouseholdMembers; native-linked adoption/reviews/exits do not. */
250
253
  enrollmentSession?:EnrollmentReader;
251
254
  /** Required for exitEnrollment and exitHousehold; the workflow owns its durable write journal. */
252
255
  exitUi?:HouseholdExitUi;
@@ -10,6 +10,7 @@ import {readProgramCatalog,flattenQuestions,createCatalogWriteLabelResolver,enco
10
10
 
11
11
  const object=v=>v!==null&&typeof v==='object'&&!Array.isArray(v);
12
12
  const positive=v=>Number.isSafeInteger(v)&&v>0;
13
+ const validGroupId=v=>positive(v)||typeof v==='string'&&/^[a-zA-Z0-9_-]+$/.test(v);
13
14
  const entryExitTypes=['HUD','Basic','Standard','RHY','VA','Basic Center Program Entry/Exit','PATH','Quick Call','Transitional Living Program Entry/Exit'];
14
15
  const canonical=v=>JSON.stringify(v,(_,x)=>object(x)?Object.fromEntries(Object.keys(x).sort().map(k=>[k,x[k]])):x);
15
16
  const hash=v=>createHash('sha256').update(canonical(v)).digest('hex');
@@ -433,7 +434,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
433
434
  for(const target of targets){fields(target,['clientId','enrollmentId']);if(!positive(target.clientId)||!positive(target.enrollmentId))fail('explicit enrollment targets required');}
434
435
  targets.sort((a,b)=>a.clientId-b.clientId);const ids=targets.map(t=>t.clientId);
435
436
  if(new Set(ids).size!==ids.length||new Set(targets.map(t=>t.enrollmentId)).size!==targets.length)fail('duplicate adoption targets');
436
- if(household&&(!positive(input.householdId)||!positive(input.headClientId)||!ids.includes(input.headClientId)||typeof api.households?.get!=='function'))fail('household, original head and complete enrollment cohort required');
437
+ if(household&&(!positive(input.householdId)||!positive(input.headClientId)||!ids.includes(input.headClientId)))fail('household, original head and complete enrollment cohort required');
437
438
  const owner=hash({adoption:true,input}),intentHash=hash(input);
438
439
  return withClients(ids,async()=>{
439
440
  const locked=household?work=>store.withLock(householdScope(input.headClientId),work):work=>work();
@@ -466,7 +467,13 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
466
467
  }
467
468
  const head=snapshots.find(r=>r.target.clientId===(household?input.headClientId:input.clientId));
468
469
  if(snapshots.some(r=>r.s.providerId!==head.s.providerId||r.s.enrollmentType!==head.s.enrollmentType||r.start<head.start||r.end>head.end))fail('invalid enrollment data: adopted members require matching program and head coverage');
469
- if(household){
470
+ const native=household&&snapshots.some(r=>r.s.householdId!==undefined);
471
+ if(native){
472
+ if(snapshots.some(r=>r.s.householdId!==input.householdId))fail('adoption household differs from enrollment association',{code:'ADOPTION_ENROLLMENT_HOUSEHOLD_MISMATCH'});
473
+ if(!validGroupId(head.s.groupId)||snapshots.some(r=>r.s.groupId!==head.s.groupId||r.s.grouped===false)||group?.context.groupId!==undefined&&group.context.groupId!==head.s.groupId)fail('adoption enrollment group changed',{code:'ADOPTION_ENROLLMENT_GROUP_MISMATCH'});
474
+ }
475
+ if(household&&!native){
476
+ if(typeof api.households?.get!=='function')fail('household, original head and complete enrollment cohort required');
470
477
  const found=toItems(await api.households.get({path:{householdId:input.householdId}}));
471
478
  if(found.length!==1||found[0].householdId!==input.householdId||found[0].isActive===false||!Array.isArray(found[0].clients))fail('active household required for adoption');
472
479
  const members=found[0].clients,heads=members.filter(m=>m.headofhouseholdStatus&&m.isActive!==false&&!m.dateLeft);
@@ -478,10 +485,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
478
485
  const context={clientId:r.target.clientId,providerId:r.s.providerId,type:r.s.enrollmentType,entryDate:new Date(r.start).toISOString()};
479
486
  const discovered=await discover(context),schemaHash=hash({providerId:discovered.provider.providerId,entry:{assessment:discovered.entry.assessment,questions:discovered.entry.definition.questions},interim:{assessment:discovered.interim.assessment,questions:discovered.interim.definition.questions}});
480
487
  const headQuestion=discovered.entry.definition.questions.find(q=>q.fieldName==='HUD_RELATIONTOHOH');
488
+ if(native&&!headQuestion&&group?.stage!=='verified')fail('native adoption requires original entry head relationship evidence',{code:'ADOPTION_HEAD_EVIDENCE_REQUIRED'});
481
489
  if(headQuestion){
482
490
  const self=headQuestion.options.filter(o=>o.code==='RELATIONHOH_SELF'),answers=questions(await api.assessments.get({path:{clientId:r.target.clientId,assessmentCode:discovered.entry.assessment.code},query:{dateEffective:context.entryDate}}));
483
491
  const answer=answers.find(q=>q.virt_field_name==='HUD_RELATIONTOHOH')?.answer_value;
484
- if(self.length!==1||answer===null||answer===undefined||answer===''||(String(answer)===String(self[0].id))!==(r.target.clientId===head.target.clientId))fail('adoption requires the original enrollment head relationship');
492
+ if(self.length!==1||answer===null||answer===undefined||answer===''||!headQuestion.options.some(o=>String(o.id)===String(answer))||(String(answer)===String(self[0].id))!==(r.target.clientId===head.target.clientId))fail('adoption requires the original enrollment head relationship');
485
493
  }
486
494
  const existing=prior.get(r.target.clientId);
487
495
  if(existing&&(existing.enrollmentId!==r.target.enrollmentId||canonical(existing.context)!==canonical(context)||existing.schemaHash!==schemaHash))fail('adopted enrollment identity or configuration changed');
@@ -491,17 +499,18 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
491
499
  const results=prepared.map(r=>({key:r.key,outcome:wasVerified?'already-present':'verified',clientId:r.context.clientId,enrollmentId:r.enrollmentId,assessmentCode:r.assessmentCode,effectiveDate:r.context.entryDate,recordsetIds:structuredClone(r.rows)}));
492
500
  if(household){
493
501
  if(!group){group={version:1,key:input.key,kind:'entry',adopted:true,intentHash,context:{householdId:input.householdId,headClientId:input.headClientId,providerId:head.s.providerId,type:head.s.enrollmentType,entryDate:new Date(head.start).toISOString()},clientIds:ids,stage:'pending',members:{},attached:true};groups.push(group);}
502
+ if(native){group.context.groupId=head.s.groupId;group.context.householdSource='native-enrollment';}
494
503
  await store.write(householdScope(input.headClientId),structuredClone(groups));
495
504
  for(const clientId of ids)await store.withLock(reservationScope(clientId),()=>store.write(reservationScope(clientId),[{owner,stage:'pending'}]));
496
505
  }
497
506
  for(const r of prepared){if(!prior.get(r.context.clientId))journals.get(r.context.clientId).push(r);await store.write(scope(r.context.clientId),structuredClone(journals.get(r.context.clientId)));}
498
- if(household){group.members=Object.fromEntries(results.map(r=>[r.clientId,r]));group.stage='verified';await store.write(householdScope(input.headClientId),structuredClone(groups));for(const clientId of ids)await store.withLock(reservationScope(clientId),()=>store.write(reservationScope(clientId),[{owner,stage:'verified'}]));return {key:input.key,outcome:wasVerified?'already-present':'verified',householdId:input.householdId,headClientId:input.headClientId,members:results};}
507
+ if(household){group.members=Object.fromEntries(results.map(r=>[r.clientId,r]));group.stage='verified';await store.write(householdScope(input.headClientId),structuredClone(groups));for(const clientId of ids)await store.withLock(reservationScope(clientId),()=>store.write(reservationScope(clientId),[{owner,stage:'verified'}]));return {key:input.key,outcome:wasVerified?'already-present':'verified',householdId:input.householdId,...(native?{groupId:head.s.groupId,householdSource:'native-enrollment'}:{}),headClientId:input.headClientId,members:results};}
499
508
  return results[0];
500
509
  });
501
510
  });
502
511
  };
503
512
  const householdRun=async(kind,raw,operation={attempted:false})=>{
504
- if(typeof enrollmentSession?.readEnrollment!=='function'||typeof helpers.addHouseholdMembers!=='function'||typeof api.households?.get!=='function')fail('household API and authoritative enrollment session required');
513
+ if(typeof enrollmentSession?.readEnrollment!=='function'||['entry','addition'].includes(kind)&&(typeof helpers.addHouseholdMembers!=='function'||typeof api.households?.get!=='function'))fail('household API and authoritative enrollment session required');
505
514
  fields(raw,kind==='addition'?['key','enrollmentKey','headClientId','entryDate','members']:kind==='entry'?['key','householdId','headClientId','providerId','type','entryDate','members']:['key','enrollmentKey','headClientId',kind==='exit'?'exitDate':'reviewDate','members',...(kind==='exit'?['destination','reasonForLeaving','destinationDetails','reasonForLeavingOther']:[])]);
506
515
  const input=structuredClone(raw);key(input.key);
507
516
  if(kind==='exit'&&(typeof exitUi?.saveExit!=='function'||typeof exitUi?.validateExit!=='function'||typeof input.destination!=='string'||!input.destination.trim()||typeof input.reasonForLeaving!=='string'||!input.reasonForLeaving.trim()))fail('exit UI, destination and reason for leaving required');
@@ -543,14 +552,25 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
543
552
  const cohortIds=[...new Set([...Object.keys(catalog).map(Number),...ids])].sort((a,b)=>a-b);
544
553
  const context=kind==='entry'?{householdId:input.householdId,headClientId:input.headClientId,providerId:input.providerId,type:input.type,entryDate:input.entryDate}:kind==='addition'?{...entry.context,entryDate:input.entryDate}:entry.context;
545
554
  if(!['entry','addition'].includes(kind)&&ids.some(id=>restDate(input.exitDate??input.reviewDate)<restDate(catalog[id].entryDate)))fail('review precedes entry');
546
- const household=toItems(await api.households.get({path:{householdId:context.householdId}}));
547
- if(household.length!==1||household[0].householdId!==context.householdId||household[0].isActive===false||!Array.isArray(household[0].clients))fail('active household required');
548
- const people=household[0].clients;
549
- if(new Set(people.map(p=>p.clientId)).size!==people.length)fail('ambiguous household membership');
550
- const heads=people.filter(p=>p.headofhouseholdStatus&&p.isActive!==false&&!p.dateLeft);
551
- if(heads.length===0)fail('invalid enrollment data: household has no active head of household');
552
- if(heads.length!==1||heads[0].clientId!==input.headClientId)fail('household head mismatch; head changes are a red flag and require separate retroactive correction or a fresh enrollment');
553
- for(const clientId of ids){const p=people.find(p=>p.clientId===clientId);if(!p||p.isActive===false||p.dateLeft||!Number.isFinite(restDate(p.dateJoined))||restDate(p.dateJoined)>restDate(catalog[clientId]?.entryDate??context.entryDate))fail('member is not active in household at entry');}
555
+ let nativeGroupId;
556
+ if(!['entry','addition'].includes(kind)){
557
+ const anchor=await enrollmentSession.readEnrollment({clientId:input.headClientId,enrollmentId:catalog[input.headClientId]?.enrollmentId});
558
+ if(anchor?.householdId!==undefined||entry.context.groupId!==undefined||record?.context.groupId!==undefined){
559
+ if(!positive(anchor?.householdId)||anchor.householdId!==context.householdId||!validGroupId(anchor?.groupId)||anchor.grouped===false||entry.context.groupId!==undefined&&entry.context.groupId!==anchor.groupId||record?.context.groupId!==undefined&&record.context.groupId!==anchor.groupId)fail('native enrollment household or group changed',{code:'HOUSEHOLD_NATIVE_LINK_CHANGED'});
560
+ nativeGroupId=anchor.groupId;
561
+ }
562
+ }
563
+ if(nativeGroupId===undefined){
564
+ if(typeof api.households?.get!=='function')fail('household API and authoritative enrollment session required');
565
+ const household=toItems(await api.households.get({path:{householdId:context.householdId}}));
566
+ if(household.length!==1||household[0].householdId!==context.householdId||household[0].isActive===false||!Array.isArray(household[0].clients))fail('active household required');
567
+ const people=household[0].clients;
568
+ if(new Set(people.map(p=>p.clientId)).size!==people.length)fail('ambiguous household membership');
569
+ const heads=people.filter(p=>p.headofhouseholdStatus&&p.isActive!==false&&!p.dateLeft);
570
+ if(heads.length===0)fail('invalid enrollment data: household has no active head of household');
571
+ if(heads.length!==1||heads[0].clientId!==input.headClientId)fail('household head mismatch; head changes are a red flag and require separate retroactive correction or a fresh enrollment');
572
+ for(const clientId of ids){const p=people.find(p=>p.clientId===clientId);if(!p||p.isActive===false||p.dateLeft||!Number.isFinite(restDate(p.dateJoined))||restDate(p.dateJoined)>restDate(catalog[clientId]?.entryDate??context.entryDate))fail('member is not active in household at entry');}
573
+ }
554
574
  for(const clientId of cohortIds){const reservations=await store.withLock(reservationScope(clientId),()=>store.read(reservationScope(clientId)));if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'&&r.owner!==owner))fail('unfinished household workflow must be reconciled first');}
555
575
  const validateSnapshot=(snapshot,target,{partial=false}={})=>{
556
576
  if(snapshot?.source!=='gwt-rpc'||snapshot.tenant!==api.tenant||snapshot.clientId!==target.clientId||snapshot.enrollmentId!==target.enrollmentId||snapshot.providerId!==context.providerId||snapshot.enrollmentType!==context.type||restDate(snapshot.projectStartDate)!==restDate(catalog[target.clientId]?.entryDate??context.entryDate))fail('household enrollment context mismatch');
@@ -559,7 +579,9 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
559
579
  if(!Array.isArray(members)||members.some(m=>!positive(m.clientId)||!positive(m.enrollmentId))||new Set(members.map(m=>m.clientId)).size!==members.length||new Set(members.map(m=>m.enrollmentId)).size!==members.length||!members.some(m=>m.clientId===target.clientId&&m.enrollmentId===target.enrollmentId))fail('invalid household enrollment members');
560
580
  const actual=members.map(m=>m.clientId).sort((a,b)=>a-b);
561
581
  if(partial?actual.some(id=>!cohortIds.includes(id)):canonical(actual)!==canonical(cohortIds))fail('household enrollment members changed');
562
- if(!Array.isArray(snapshot.householdClientIds)||cohortIds.some(id=>!snapshot.householdClientIds.includes(id)))fail('household members unavailable');
582
+ const pinnedGroup=nativeGroupId??context.groupId;
583
+ if(pinnedGroup!==undefined){if(snapshot.householdId!==context.householdId||snapshot.groupId!==pinnedGroup||snapshot.grouped===false)fail('native enrollment household or group changed',{code:'HOUSEHOLD_NATIVE_LINK_CHANGED'});}
584
+ if(nativeGroupId===undefined&&(!Array.isArray(snapshot.householdClientIds)||cohortIds.some(id=>!snapshot.householdClientIds.includes(id))))fail('household members unavailable');
563
585
  for(const m of members){const known=record?.members[m.clientId]?.enrollmentId??catalog[m.clientId]?.enrollmentId;if(known&&known!==m.enrollmentId)fail('household enrollment identity changed');}
564
586
  return snapshot;
565
587
  };
@@ -589,6 +611,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
589
611
  const preflight=[];for(const member of input.members)preflight.push(await run(kind==='addition'?'entry':kind,toMember(member),{locked:true,owner,operation,headClientId:input.headClientId,preflight:true,...(kind==='addition'?{pinSchema:true}:{})}));
590
612
  if(kind==='addition'&&record?.entrySchemas&&canonical(record.entrySchemas)!==canonical(preflight))fail('additional member assessment configuration changed');
591
613
  if(kind==='exit'&&record?.exitSchemas&&canonical(record.exitSchemas)!==canonical(preflight))fail('exit assessment configuration changed');
614
+ if(nativeGroupId!==undefined){context.groupId=nativeGroupId;context.householdSource='native-enrollment';entry.context.groupId=nativeGroupId;entry.context.householdSource='native-enrollment';if(record){record.context.groupId=nativeGroupId;record.context.householdSource='native-enrollment';}}
592
615
  const wasVerified=record?.stage==='verified';
593
616
  if(!record){record={version:1,key:input.key,kind,intentHash,context,...(input.enrollmentKey?{enrollmentKey:input.enrollmentKey}:{}),clientIds:ids,stage:'pending',members:{},...(kind==='exit'?{exitSchemas:preflight}:kind==='addition'?{entrySchemas:preflight}:{})};records.push(record);}
594
617
  const persist=()=>store.write(householdScope(input.headClientId),structuredClone(records));await persist();
@@ -748,7 +771,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
748
771
  if(kind==='exit'||kind==='addition')await preserve();
749
772
  await validateHeadCoverage();
750
773
  record.stage='verified';await persist();for(const clientId of cohortIds)await store.withLock(reservationScope(clientId),()=>store.write(reservationScope(clientId),[{owner,stage:'verified'}]));
751
- return {key:input.key,outcome:wasVerified?'already-present':'verified',householdId:context.householdId,headClientId:input.headClientId,members:ids.map(id=>structuredClone(record.members[id]))};
774
+ return {key:input.key,outcome:wasVerified?'already-present':'verified',householdId:context.householdId,...(context.groupId!==undefined?{groupId:context.groupId,householdSource:context.householdSource}:{}),headClientId:input.headClientId,members:ids.map(id=>structuredClone(record.members[id]))};
752
775
  }));
753
776
  };
754
777
  return {
@@ -767,7 +790,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
767
790
  const records=await store.read(householdScope(input.headClientId));if(!Array.isArray(records))fail('invalid household journal');
768
791
  const matches=records.filter(r=>r.key===input.key);if(matches.length>1)fail('duplicate household journal keys');const record=matches[0];if(!record)return null;
769
792
  if(record.version!==1||!object(record.members))fail('invalid household journal');
770
- return {key:record.key,kind:record.kind,stage:record.stage,householdId:record.context.householdId,headClientId:input.headClientId,members:record.clientIds.map(clientId=>({clientId,key:memberKey(input.key,clientId),...structuredClone(record.members[clientId]??{})}))};
793
+ return {key:record.key,kind:record.kind,stage:record.stage,householdId:record.context.householdId,...(record.context.groupId!==undefined?{groupId:record.context.groupId,householdSource:record.context.householdSource}:{}),headClientId:input.headClientId,members:record.clientIds.map(clientId=>({clientId,key:memberKey(input.key,clientId),...structuredClone(record.members[clientId]??{})}))};
771
794
  });
772
795
  });
773
796
  },
@@ -3,24 +3,33 @@ import {parseRestInstant,toItems} from '../dist/esm/index.js';
3
3
  const positive=v=>Number.isSafeInteger(v)&&v>0;
4
4
  const pairs=members=>JSON.stringify(members.map(({clientId,enrollmentId})=>({clientId,enrollmentId})).sort((a,b)=>a.clientId-b.clientId));
5
5
 
6
- /** Resolves only a unique current household satisfying the adoption membership contract. */
6
+ /** Read the native household association; older custom readers retain explicitly labeled membership matching. */
7
7
  export function createHouseholdEnrollmentDiscovery({api,enrollmentSession,makeError}){
8
8
  const fail=(code,reason)=>{throw makeError(reason,{code,committed:false});};
9
9
  return async input=>{
10
10
  if(!input||Object.keys(input).some(k=>!['headClientId','headEnrollmentId'].includes(k))||!positive(input.headClientId)||!positive(input.headEnrollmentId))fail('HOUSEHOLD_DISCOVERY_INPUT_INVALID','head client and enrollment IDs required');
11
- if(typeof enrollmentSession?.readEnrollment!=='function'||typeof api.households?.listForClient!=='function'||typeof api.households?.get!=='function')fail('HOUSEHOLD_DISCOVERY_READERS_REQUIRED','GWT detail and complete household readers required');
11
+ if(typeof enrollmentSession?.readEnrollment!=='function')fail('HOUSEHOLD_DISCOVERY_READERS_REQUIRED','authoritative enrollment reader required');
12
12
  const target={clientId:input.headClientId,enrollmentId:input.headEnrollmentId};
13
13
  const anchor=await enrollmentSession.readEnrollment(target);
14
14
  const valid=(s,t)=>s?.source==='gwt-rpc'&&s.tenant===api.tenant&&s.clientId===t.clientId&&s.enrollmentId===t.enrollmentId&&positive(s.providerId)&&typeof s.enrollmentType==='string'&&Number.isFinite(parseRestInstant(s.projectStartDate));
15
15
  if(!valid(anchor,target)||!Array.isArray(anchor.members)||!anchor.members.length||anchor.members.some(m=>!positive(m?.clientId)||!positive(m?.enrollmentId))||new Set(anchor.members.map(m=>m.clientId)).size!==anchor.members.length||new Set(anchor.members.map(m=>m.enrollmentId)).size!==anchor.members.length||!anchor.members.some(m=>m.clientId===target.clientId&&m.enrollmentId===target.enrollmentId))fail('HOUSEHOLD_DISCOVERY_DETAIL_INVALID','complete linked enrollment cohort required');
16
16
  if(anchor.members.length===1&&anchor.grouped!==true)fail('ENROLLMENT_NOT_GROUPED','standalone enrollment uses adoptEnrollment');
17
17
  if(anchor.grouped===false&&anchor.members.length>1)fail('HOUSEHOLD_DISCOVERY_DETAIL_INVALID','group flag conflicts with linked cohort');
18
+ const native=anchor.householdId!==undefined,validGroup=v=>Number.isSafeInteger(v)&&v>0||typeof v==='string'&&/^[a-zA-Z0-9_-]+$/.test(v);
19
+ if(native&&(!positive(anchor.householdId)||!validGroup(anchor.groupId)))fail('HOUSEHOLD_DISCOVERY_NATIVE_LINK_REQUIRED','native household and enrollment group IDs required');
18
20
  const members=anchor.members.map(({clientId,enrollmentId})=>({clientId,enrollmentId})).sort((a,b)=>a.clientId-b.clientId),starts=new Map();
19
21
  for(const member of members){
20
22
  const s=member.clientId===target.clientId?anchor:await enrollmentSession.readEnrollment(member);
21
23
  if(!valid(s,member)||s.providerId!==anchor.providerId||s.enrollmentType!==anchor.enrollmentType||!Array.isArray(s.members)||pairs(s.members)!==pairs(members))fail('HOUSEHOLD_DISCOVERY_COHORT_CHANGED','linked enrollment cohort changed during discovery');
24
+ if(native&&(s.householdId!==anchor.householdId||s.groupId!==anchor.groupId||s.grouped===false))fail('HOUSEHOLD_DISCOVERY_COHORT_CHANGED','native household or enrollment group changed during discovery');
22
25
  starts.set(member.clientId,parseRestInstant(s.projectStartDate));
23
26
  }
27
+ if(native){
28
+ const fresh=await enrollmentSession.readEnrollment(target);
29
+ if(!valid(fresh,target)||fresh.householdId!==anchor.householdId||fresh.groupId!==anchor.groupId||fresh.providerId!==anchor.providerId||fresh.enrollmentType!==anchor.enrollmentType||!Array.isArray(fresh.members)||pairs(fresh.members)!==pairs(members))fail('HOUSEHOLD_DISCOVERY_COHORT_CHANGED','native household or enrollment cohort changed during discovery');
30
+ return {...input,householdId:anchor.householdId,householdSource:'native-enrollment',groupId:anchor.groupId,providerId:anchor.providerId,type:anchor.enrollmentType,members};
31
+ }
32
+ if(typeof api.households?.listForClient!=='function'||typeof api.households?.get!=='function')fail('HOUSEHOLD_DISCOVERY_READERS_REQUIRED','legacy readers require complete household readers');
24
33
  const candidates=[],seen=new Set();let total;
25
34
  for(let startPage=1;startPage<=100;startPage++){
26
35
  const result=await api.households.listForClient({path:{clientId:input.headClientId},query:{perPage:100,startPage,total_count:true}});
@@ -17,12 +17,14 @@ export class LoginError extends Error {
17
17
  }
18
18
  export interface UiSession {readonly connection:UiConnection;browser: Browser;context: BrowserContext;page: Page;passwordRecovery:'restored-original'|'not-needed';ensureAuthenticated():Promise<void>;reauthenticate():Promise<void>;close():Promise<void>;}
19
19
  export interface PlaywrightGwtOptions {page:Page;tenant:string;moduleBaseUrl:string;}
20
- export interface PlaywrightEnrollmentSnapshot extends GwtEnrollmentSnapshot {providerId:number;enrollmentType:string;exits:readonly GwtEnrollmentExit[];groupId?:number|string|null;grouped?:boolean;}
20
+ export interface PlaywrightEnrollmentSnapshot extends GwtEnrollmentSnapshot {providerId:number;enrollmentType:string;exits:readonly GwtEnrollmentExit[];groupId?:number|string|null;grouped?:boolean;householdId?:number|null;}
21
21
  export interface EnrollmentListRow {
22
22
  enrollmentId:number;clientId:number;enrollmentProvider:number;type:string;
23
23
  projectStartDate:string;projectExitDate:string|null;isActive:boolean;
24
24
  /** An enrollment group pointer, not a household identifier or member count. */
25
25
  groupId:number|string|null;grouped:boolean;
26
+ /** Native enrollment association, distinct from groupId and current household membership. */
27
+ householdId?:number|null;
26
28
  }
27
29
  export interface EnrollmentListSnapshot {
28
30
  source:'gwt-rpc';tenant:string;clientId:number;count:number;values:EnrollmentListRow[];
@@ -114,11 +114,12 @@ export function createPlaywrightGwtSession({page,tenant,moduleBaseUrl}) {
114
114
  };
115
115
  const listEnrollments=async({clientId})=>{
116
116
  await openReadContext(clientId);
117
- const rows=await mapped(`SELECT e.entryExitId AS enrollmentId, e.clientId AS clientId, e.entryDate AS projectStartDate, e.exitDate AS projectExitDate, e.providerId AS enrollmentProvider, e.typeEntryExit.name AS type, e.groupId AS groupId FROM SpEntryExit AS e WHERE e.active = TRUE AND e.client.active = TRUE AND e.clientId = ${clientId} ORDER BY e.entryExitId`);
117
+ const rows=await mapped(`SELECT e.entryExitId AS enrollmentId, e.clientId AS clientId, e.entryDate AS projectStartDate, e.exitDate AS projectExitDate, e.providerId AS enrollmentProvider, e.typeEntryExit.name AS type, e.groupId AS groupId, e.householdId AS householdId FROM SpEntryExit AS e WHERE e.active = TRUE AND e.client.active = TRUE AND e.clientId = ${clientId} ORDER BY e.entryExitId`);
118
118
  const ids=new Set();
119
119
  for(const row of rows){
120
120
  if(!Number.isSafeInteger(row.enrollmentId)||row.enrollmentId<=0||row.clientId!==clientId||!Number.isSafeInteger(row.enrollmentProvider)||row.enrollmentProvider<=0||typeof row.type!=='string'||!row.type.trim()||typeof row.projectStartDate!=='string'||!Number.isFinite(Date.parse(row.projectStartDate))||row.projectExitDate!==null&&(typeof row.projectExitDate!=='string'||!Number.isFinite(Date.parse(row.projectExitDate)))||ids.has(row.enrollmentId))throw Error('Invalid enrollment list');
121
121
  ids.add(row.enrollmentId);
122
+ if(row.householdId!==null&&(!Number.isSafeInteger(row.householdId)||row.householdId<=0))throw Error('Invalid enrollment household ID');
122
123
  if(row.groupId!==null){if(typeof row.groupId==='number'&&row.groupId<=0)throw Error('Invalid enrollment group');literal(row.groupId);}
123
124
  }
124
125
  return {source:'gwt-rpc',tenant,clientId,count:rows.length,values:rows.map(row=>({...row,isActive:true,grouped:row.groupId!==null}))};
@@ -137,9 +138,10 @@ export function createPlaywrightGwtSession({page,tenant,moduleBaseUrl}) {
137
138
  };
138
139
  const readEnrollment=async target=>{
139
140
  const model=await openTarget(target),householdClientIds=await householdIds(target);
140
- const entries=await mapped(`SELECT e.entryExitId AS enrollmentId, e.clientId AS clientId, e.entryDate AS projectStartDate, e.groupId AS groupId, e.providerId AS providerId, e.typeEntryExit.name AS enrollmentType FROM SpEntryExit AS e WHERE e.active = TRUE AND e.entryExitId = ${id(target.enrollmentId)} AND e.clientId = ${id(target.clientId)}`);
141
+ const entries=await mapped(`SELECT e.entryExitId AS enrollmentId, e.clientId AS clientId, e.entryDate AS projectStartDate, e.groupId AS groupId, e.householdId AS householdId, e.providerId AS providerId, e.typeEntryExit.name AS enrollmentType FROM SpEntryExit AS e WHERE e.active = TRUE AND e.entryExitId = ${id(target.enrollmentId)} AND e.clientId = ${id(target.clientId)}`);
141
142
  if(entries.length!==1)throw Error('Enrollment missing');const entry=entries[0];
142
143
  if(entry.enrollmentId!==target.enrollmentId||entry.clientId!==target.clientId||!Number.isSafeInteger(entry.providerId)||entry.providerId<=0||typeof entry.enrollmentType!=='string'||!entry.enrollmentType.trim())throw Error('Enrollment context incomplete');
144
+ if(entry.householdId!==null&&(!Number.isSafeInteger(entry.householdId)||entry.householdId<=0))throw Error('Invalid enrollment household ID');
143
145
  const members=entry.groupId===null?[{clientId:target.clientId,enrollmentId:target.enrollmentId}]:await mapped(`SELECT e.entryExitId AS enrollmentId, e.clientId AS clientId FROM SpEntryExit AS e WHERE e.active = TRUE AND e.client.active = TRUE AND e.groupId = ${literal(entry.groupId)}`);
144
146
  members.sort((a,b)=>a.clientId-b.clientId);
145
147
  const exitRows=await mapped(`SELECT e.entryExitId AS enrollmentId, e.clientId AS clientId, e.exitDate AS projectExitDate, e.destinationValue.name AS destination, e.destinationValue.code AS destinationCode, e.reasonLeavingValue.name AS reasonForLeaving, e.reasonLeavingValue.code AS reasonForLeavingCode, e.exitSubgroupId AS exitSubgroupId FROM SpEntryExit AS e WHERE e.active = TRUE AND e.client.active = TRUE AND ${entry.groupId===null?`e.entryExitId = ${id(target.enrollmentId)}`:`e.groupId = ${literal(entry.groupId)}`}`);
@@ -165,7 +167,7 @@ export function createPlaywrightGwtSession({page,tenant,moduleBaseUrl}) {
165
167
  reviews.push({reviewId:row.reviewId,type:row.type,reviewDate:row.reviewDate,clientIds:clients.map(c=>c.clientId).sort((a,b)=>a-b)});
166
168
  }
167
169
  reviews.sort((a,b)=>a.reviewId-b.reviewId);
168
- const data={source:'gwt-rpc',tenant,sessionId,...target,projectStartDate:entry.projectStartDate,providerId:entry.providerId,enrollmentType:entry.enrollmentType,groupId:entry.groupId,grouped:entry.groupId!==null,householdClientIds,members,exits,reviews};
170
+ const data={source:'gwt-rpc',tenant,sessionId,...target,projectStartDate:entry.projectStartDate,providerId:entry.providerId,enrollmentType:entry.enrollmentType,groupId:entry.groupId,grouped:entry.groupId!==null,householdId:entry.householdId,householdClientIds,members,exits,reviews};
169
171
  return {...data,revision:digest(model+JSON.stringify({...data,sessionId:undefined}))};
170
172
  };
171
173
  const chooseMembers=async(desired,implicitClientId,dialogButton)=>{
@@ -105,3 +105,9 @@ The 0.7.0 label resolver, group flags, household discovery, row registration, er
105
105
  Read-only training checks matched all 1,288 distinct option IDs in the Coordinated Entry instrument to current GWT catalog rows across three synthetic clients (4,089 question/option pairs). Catalog names differed from REST metadata names for 139 option occurrences in one definition. The five enhanced yes/no HUD questions shared a catalog; plain yes/no alternatives had separate option IDs. The visible profile DOB-quality dropdown matched the catalog's full labels. This establishes metadata identity and displayed labels without prior-answer evidence, not fresh acceptance of every standard write. Existing enrollment and answer records were left unchanged.
106
106
 
107
107
  SDK 0.8.0 uses the catalog automatically for workflows with a built-in enrollment session. REST-only applications can supply a catalog resolver or retain their explicit callback. See [catalog evidence and limitations](vendor-issues/lookup-write-labels.md).
108
+
109
+ ## Native enrollment household identity, November 11, 2026
110
+
111
+ Read-only GWT checks of four existing Coordinated Entry groups (eight enrollments) returned both `groupId` and a distinct native `householdId`, including exited groups. The SDK's updated managed reader independently verified six enrollments across three groups. REST complete reads still returned HTTP 400 for the grouped controls, while standalone controls returned null group and household IDs.
112
+
113
+ Using a temporary private journal, native discovery and `adoptHouseholdEnrollment` succeeded for one open and one exited two-person group using only the head's client/enrollment IDs. Both same-key retries returned `already-present`. The workflow was given no household REST reader; no WellSky record-write requests were made. These results verify native reads and read-only adoption, not new live interim or exit writes after household changes. Synthetic tests cover those later operations, identity drift, original-head evidence, current-membership requirements for additions, and legacy-key retries. See [the identity evidence](vendor-issues/enrollment-household-identity.md).
@@ -232,7 +232,7 @@ const detail = await session.enrollmentSession.readEnrollment({
232
232
  // detail.members contains the complete linked clientId/enrollmentId pairs.
233
233
  ```
234
234
 
235
- Use the session returned by `openEnrollmentSession`. Lists include open and exited entries, validate complete counts, and refuse more than 1,000 records. List rows and details include `groupId` and `grouped`. The flag describes a stored group link, including a group with one participant; it is not a member count and requires no per-row detail reads. These fields are required on managed catalog rows and optional on custom detail-reader contracts for compatibility. `groupId` is never a household ID. For a verified household match from a head enrollment, use `discoverHouseholdEnrollment` below.
235
+ Use the session returned by `openEnrollmentSession`. Lists include open and exited entries, validate complete counts, and refuse more than 1,000 records. List rows and details include `groupId` and `grouped`. The flag describes a stored group link, including a group with one participant; it is not a member count and requires no per-row detail reads. These fields are required on managed catalog rows and optional on custom detail-reader contracts for compatibility. `groupId` is never a household ID. Managed lists and details also return `householdId`, read directly from the enrollment record; `null` means no recorded association. It is optional on custom reader contracts for compatibility. Use `discoverHouseholdEnrollment` below to verify that association across the complete cohort.
236
236
 
237
237
  The same session also exposes `readPicklistOptions({clientId, optionIds})` for exact labels before answers exist. It uses fresh read context, validates counted results, and makes no record writes. Enrollment workflows use it automatically unless `writeLabel` is supplied. See [catalog lookup labels](assessments.md#resolve-lookup-labels-before-an-answer-exists).
238
238
 
@@ -262,7 +262,7 @@ For a person in a linked household enrollment, use `exitHousehold` with that per
262
262
 
263
263
  ## Adopt an enrollment entered manually in WellSky
264
264
 
265
- Use `adoptEnrollment({key, clientId, enrollmentId})` for a standalone enrollment. For linked household enrollments, use `adoptHouseholdEnrollment({key, householdId, headClientId, members})`, where each member supplies both `clientId` and `enrollmentId`. Supply the complete linked cohort, including members already exited, and the original enrollment head. Adoption verifies authoritative GWT identities, program/type, entry dates, current household membership, head relationships when present, and head coverage across all enrollment intervals.
265
+ Use `adoptEnrollment({key, clientId, enrollmentId})` for a standalone enrollment. For linked household enrollments, use `adoptHouseholdEnrollment({key, householdId, headClientId, members})`, where each member supplies both `clientId` and `enrollmentId`. Supply the complete linked cohort, including members already exited, and the original enrollment head. Adoption verifies authoritative GWT identities, program/type, entry dates, the native household/group association when available, original head relationships, and head coverage across all enrollment intervals. An explicit household ID must match the native enrollment field. Legacy custom readers without that field retain their current-household validation.
266
266
 
267
267
  Adoption writes only to the local durable journal. It does not create, attach, exit or modify an HMIS record. Later entrants retain their own original entry dates. An enrollment already tracked under another entry key is rejected; use its existing key. Retry an interrupted adoption with the same inputs and store.
268
268
 
@@ -274,13 +274,20 @@ After standalone adoption, its key can be used as `enrollmentKey` for `createInt
274
274
  const discovered = await workflow.discoverHouseholdEnrollment({
275
275
  headClientId, headEnrollmentId,
276
276
  });
277
- // discovered: householdId, householdSource, providerId, type, members
277
+ // Built-in reader: householdId, groupId, householdSource: "native-enrollment", providerId, type, members
278
278
  const adopted = await workflow.adoptHouseholdEnrollment({
279
279
  key: sourceEnrollmentKey, headClientId, headEnrollmentId,
280
280
  });
281
281
  ```
282
282
 
283
- Discovery reads the linked enrollment cohort through GWT and matches it against complete current household membership reads. It requires one household containing the cohort, the same head, and membership dates covering each entry. It verifies that household again before returning `householdSource: 'verified-membership'`. This is a verified membership match, not a native historical household pointer on the enrollment. Zero matches, multiple matches, incomplete reads or changed relationships fail without adopting. A unique current match does not prove the enrollment's original historical household association. If that original association is required, verify it independently; the SDK does not expose a native historical pointer. Explicit adoption inputs still require active household membership, the matching head and valid dates. Known historical IDs cannot bypass those guards, so inactive or changed historical households remain unsupported. No record is written to WellSky during discovery or adoption.
283
+ WellSky stores both `SpEntryExit.groupId` and `SpEntryExit.householdId`. With a built-in reader, discovery reads the household ID directly from the enrollment, verifies that every linked participant has the same household and group IDs, and rechecks the anchor before returning `householdSource: 'native-enrollment'`. It does not search current households to infer the association. Missing native IDs, mixed associations, incomplete cohorts or changed identities stop discovery. A present-but-null native household field never falls back to a membership guess.
284
+
285
+ Native adoption also verifies the original head using entry-time `HUD_RELATIONTOHOH` answers and checks that the head's enrollment covers every member's interval. New native adoptions without that head evidence are refused. Existing verified journal keys retain their accepted head identity and intent hashes. Adoption only records identities locally; it makes no WellSky record writes.
286
+
287
+ Interims and exits for native-linked groups validate the recorded household/group IDs and enrolled participants without requiring them to remain in an active current household. Group identities are pinned in the journal; drift blocks later operations. This allows recorded groups to remain usable after current household membership changes. Enrollment-head replacement remains unsupported, and the original head must still cover the members' enrollment dates. Creation and later member additions continue to require valid current household membership.
288
+
289
+ Legacy custom detail readers that omit `householdId` retain the older unique-current-membership discovery, explicitly labeled `householdSource: 'verified-membership'`. That fallback does not prove a historical association. Upgrade those adapters to return the native `householdId` and `groupId` to use the new path. Existing keys whose original IDs agree remain valid; a conflicting historical guess is refused rather than silently rebound. See [native household evidence](vendor-issues/enrollment-household-identity.md).
290
+
284
291
 
285
292
  ### Register existing entry rows
286
293
 
package/docs/reference.md CHANGED
@@ -37,7 +37,7 @@ Runtime exports: `AttachmentConflictError`, `CoordinatedEntryEventConflictError`
37
37
  | Profile fields and Sex | [Client profile](../browser/client-profile.d.mts) | `read`, `update` |
38
38
  | Calendar DOB | [Birth dates](../browser/date-of-birth.d.mts) | `read`, `validate`, `verify`, `update` |
39
39
  | Provider assessment defaults | [Discovery](../browser/program-discovery.d.mts) | `listAssessments`, `getProviderAssessments` |
40
- | Enrollment and household lifecycle | [Workflow](../browser/enrollment-workflow.d.mts) | Discovery, entry, `correctEntry`, `resolveEntryCorrection`, `adoptEnrollment`, `adoptHouseholdEnrollment`, `discoverHouseholdEnrollment`, `registerEntryRows`, member additions, interim, `exitEnrollment`, `exitHousehold`, journal status; `EntryExitType` and per-type `assessmentLocations` in [enrollments](enrollments.md) |
40
+ | Enrollment and household lifecycle | [Workflow](../browser/enrollment-workflow.d.mts) | Discovery, entry, `correctEntry`, `resolveEntryCorrection`, `adoptEnrollment`, `adoptHouseholdEnrollment`, `discoverHouseholdEnrollment`, `registerEntryRows`, native household/group identity, member additions, interim, `exitEnrollment`, `exitHousehold`, journal status; `EntryExitType` and per-type `assessmentLocations` in [enrollments](enrollments.md) |
41
41
  | Entry-date and exit UI ports | [Member entry](../browser/member-entry-date.d.mts), [exit](../browser/household-exit.d.mts) | Fresh reads and targeted completion |
42
42
  | Services and recovery diagnostics | [Service recording](../browser/service-recording.d.mts) | `record`, `recordBulk`, Funding Sources, `RecoveryDiagnostic` |
43
43
  | SSVF conditional rules | [SSVF types](../browser/ssvf-rules.d.mts) | [Field rules](../SSVF.md) and dated option snapshots |
@@ -0,0 +1,13 @@
1
+ # Enrollment household identity
2
+
3
+ On November 11, 2026, authenticated read-only training checks showed that WellSky exposes both an enrollment group ID and a household ID on `SpEntryExit`. The SDK had selected only the group ID. A group ID must never be substituted for a household ID.
4
+
5
+ The application's current `MetaQueryService` / `GenericService.getMappedResults` accepted a projection of `entryExitId`, `clientId`, `groupId`, `householdId`, provider and enrollment dates. Four existing Coordinated Entry groups, containing eight enrollments, had consistent native household IDs within each group; the household IDs differed from the group IDs. Three of those groups were exited. This establishes the recorded enrollment association without matching current household membership. It does not claim the field is an immutable audit history.
6
+
7
+ The [managed reader](../../browser/playwright-session.mjs) now selects and validates the native field in both catalog and detail reads. SDK reads independently verified six enrollments across three groups. Native discovery and adoption then succeeded on an open group and an exited group, including same-key no-write retries, using a temporary local journal. No enrollment, household, assessment, review, exit or password was changed. Raw captures and client identifiers are excluded from the package.
8
+
9
+ The pinned REST schema already declares `householdId`, but grouped complete reads returned HTTP 400 in the read-only controls. Standalone control reads succeeded with null household and group IDs. The existing [grouped REST defect](enrollment-effective-date-column.md) still applies.
10
+
11
+ Native adoption verifies the complete cohort's recorded household/group identities and entry-time head relationship. Reviews and exits retain the original enrollment head and interval coverage while using the native association. Current household membership continues to constrain creation and member additions. A missing native field in an older custom reader retains the explicitly labeled legacy path; a null or inconsistent native field is not guessed from current membership.
12
+
13
+ See [adoption and discovery](../enrollments.md#discover-a-household-from-its-head-enrollment) for usage and compatibility limits.
@@ -1,6 +1,6 @@
1
1
  import {createEnrollmentWorkflow,type EnrollmentWorkflowOptions,type EntryCorrectionInput} from '@choiceapps/communityservices-sdk/browser';
2
2
 
3
- /** The head's explicit target identifies a cohort; ambiguous household matches fail. */
3
+ /** Built-in readers verify the native household and group; older adapters identify their membership fallback explicitly. */
4
4
  export async function adoptFromHead(dependencies:EnrollmentWorkflowOptions,input:{key:string;headClientId:number;headEnrollmentId:number}){
5
5
  const workflow=createEnrollmentWorkflow(dependencies);
6
6
  const discovery=await workflow.discoverHouseholdEnrollment({headClientId:input.headClientId,headEnrollmentId:input.headEnrollmentId});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@choiceapps/communityservices-sdk",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
4
4
  "description": "SDK for WellSky Community Services (HMIS), formerly ServicePoint, Client API V2 and V3",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",