@choiceapps/communityservices-sdk 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 - 2026-11-10
4
+
5
+ - Use a configured GWT enrollment catalog for creation and member preflight. Prior household enrollments, including exited entries, no longer force the known-broken REST grouped list. Failed or incomplete reads never imply absence.
6
+ - Add `EnrollmentReadError` to distinguish a preflight non-commit from an unavailable read after a write attempt.
7
+ - Add `correctEntry` for explicit scalar answers/clears and in-place edits to rows created by the original entry workflow. Preserve row IDs, other values and same-key recovery; original entry retries respect verified corrections.
8
+ - Add read-only `adoptEnrollment` and `adoptHouseholdEnrollment` operations so manually entered enrollments can use the journaled exit workflows.
9
+ - Record integrator-reported November 10, 2026 sandbox acceptance of 0.4.1 individual and household exits. The newly added catalog, correction and adoption paths have synthetic coverage; they have not been independently exercised against a live tenant.
10
+
11
+ ## 0.4.1 - 2026-11-10
12
+
13
+ - Pin GitHub verification and publication jobs to Ubuntu 24.04. The Ubuntu 26.04 runner selected by `ubuntu-latest` cannot install Chromium with the pinned Playwright version, which blocked public npm publication of 0.4.0. SDK behavior is unchanged.
14
+
15
+ ## 0.4.0 - 2026-11-10
16
+
17
+ - Add `exitEnrollment` for standalone program enrollments, with durable save journaling, authoritative exit verification, conditional destination/reason details and per-person exit assessments. Linked household participants continue to use `exitHousehold`, including whole-household and selected-member exits.
18
+ - Support a standalone exit form without a household checkbox tree only when fresh enrollment data confirms exactly one participant. Initial acceptance covered synthetic regressions. Subsequent integrator-reported sandbox acceptance is recorded under capabilities on November 10, 2026.
19
+
3
20
  ## 0.3.0 - 2026-11-03
4
21
 
5
22
  - Compare journaled attachment and case-note intents, and shelter stay identities, by canonical hash instead of key-order-sensitive JSON, so an identical retry no longer conflicts when a store such as Postgres `jsonb` reorders keys. Existing journals remain valid.
@@ -57,6 +57,34 @@ export interface InterimWorkflowInput {
57
57
  reviewDate:string;
58
58
  assessment:EnrollmentAssessmentPlan;
59
59
  }
60
+ /** A standalone enrollment created or adopted by createEnrollment in this store. */
61
+ export interface ExitEnrollmentInput extends HouseholdExitDetailInput {
62
+ key:string;
63
+ enrollmentKey:string;
64
+ clientId:number;
65
+ exitDate:string;
66
+ destination:string;
67
+ reasonForLeaving:string;
68
+ assessment:EnrollmentAssessmentPlan;
69
+ }
70
+ export interface EntryRowCorrection {
71
+ /** Original subassessment row key from createEnrollment, never a new row key. */
72
+ key:string;parentField:string;answers?:Answers;clear?:string[];
73
+ }
74
+ export interface EntryCorrectionInput {
75
+ key:string;enrollmentKey:string;clientId:number;
76
+ answers?:Answers;
77
+ /** Explicit clears; an omitted field stays unchanged. Clear is distinct from No. */
78
+ clear?:string[];
79
+ subassessments?:EntryRowCorrection[];
80
+ }
81
+ export interface EntryCorrectionResult extends EnrollmentWorkflowResult {enrollmentKey:string;}
82
+ export interface AdoptEnrollmentInput {key:string;clientId:number;enrollmentId:number;}
83
+ export interface AdoptHouseholdEnrollmentInput {
84
+ key:string;householdId:number;headClientId:number;
85
+ /** Complete linked cohort, including already exited participants. IDs are explicit, not inferred. */
86
+ members:Array<{clientId:number;enrollmentId:number}>;
87
+ }
60
88
  export interface EnrollmentWorkflowResult {
61
89
  key:string;
62
90
  outcome:'verified'|'already-present';
@@ -71,16 +99,18 @@ export interface EnrollmentWorkflowResult {
71
99
  }
72
100
  export interface InterimWorkflowResult extends EnrollmentWorkflowResult {reviewId:number;}
73
101
  export interface ExitWorkflowResult extends EnrollmentWorkflowResult {projectExitDate:string;exitSubgroupId:string;}
102
+ /** An individual exit may have no linked exit subgroup. */
103
+ export interface ExitEnrollmentResult extends EnrollmentWorkflowResult {projectExitDate:string;exitSubgroupId:string|null;}
74
104
  export interface ExitWorkflowDiscovery extends EnrollmentWorkflowDiscovery {exit:{assessment:ProviderAssessmentItem;definition:AssessmentDefinition};}
75
105
  export interface EnrollmentWorkflowStatus {
76
106
  key:string;
77
- kind:'entry'|'interim'|'exit';
107
+ kind:'entry'|'interim'|'exit'|'correction';
78
108
  stage:'pending'|'verified';
79
109
  assessmentCode:string;
80
110
  enrollmentId?:number;
81
111
  reviewId?:number;
82
112
  projectExitDate?:string;
83
- exitSubgroupId?:string;
113
+ exitSubgroupId?:string|null;
84
114
  recordsetIds:Record<string,number>;
85
115
  steps:{name:string;state:'attempted'|'verified'}[];
86
116
  }
@@ -89,6 +119,14 @@ export interface EnrollmentWorkflow {
89
119
  discoverExit(input:EnrollmentDiscoveryInput):Promise<ExitWorkflowDiscovery>;
90
120
  createEnrollment(input:EnrollmentWorkflowInput):Promise<EnrollmentWorkflowResult>;
91
121
  createInterim(input:InterimWorkflowInput):Promise<InterimWorkflowResult>;
122
+ /** Standalone participant only. Linked household participants use exitHousehold. */
123
+ exitEnrollment(input:ExitEnrollmentInput):Promise<ExitEnrollmentResult>;
124
+ /** Patch an open entry at its original timestamp, before any review or exit. */
125
+ correctEntry(input:EntryCorrectionInput):Promise<EntryCorrectionResult>;
126
+ /** Verify an existing standalone entry and journal it without an HMIS write. */
127
+ adoptEnrollment(input:AdoptEnrollmentInput):Promise<EnrollmentWorkflowResult>;
128
+ /** Verify all existing linked entries, original head and dates; no HMIS writes. */
129
+ adoptHouseholdEnrollment(input:AdoptHouseholdEnrollmentInput):Promise<HouseholdEnrollmentResult>;
92
130
  /** Reads the local journal only. A verified journal is not fresh server verification. */
93
131
  getStatus(input:{clientId:number;key:string}):Promise<EnrollmentWorkflowStatus|null>;
94
132
  createHouseholdEnrollment(input:HouseholdEnrollmentInput):Promise<HouseholdEnrollmentResult>;
@@ -164,9 +202,9 @@ export interface EnrollmentWorkflowOptions {
164
202
  store:UiMutationStore;
165
203
  /** Required for SVPPROFDOB answers or carry-forward. Share this workflow's durable store. */
166
204
  birthDates?:DateOfBirth;
167
- /** Required, along with api.households and helpers.addHouseholdMembers, for household operations. */
205
+ /** Required for exits; also required with api.households and helpers.addHouseholdMembers for household operations. */
168
206
  enrollmentSession?:EnrollmentReader;
169
- /** Required for exitHousehold; the workflow owns its durable write journal. */
207
+ /** Required for exitEnrollment and exitHousehold; the workflow owns its durable write journal. */
170
208
  exitUi?:HouseholdExitUi;
171
209
  /** Required to set the later entry date for newly attached household members. */
172
210
  memberEntryDatePort?:MemberEntryDatePort;
@@ -183,6 +221,16 @@ export class EnrollmentWorkflowError extends Error {
183
221
  readonly step?:string;
184
222
  constructor(reason:string,options?:ErrorOptions&{step?:string});
185
223
  }
224
+ /** An unavailable list is never evidence that no enrollment exists. */
225
+ export class EnrollmentReadError extends EnrollmentWorkflowError {
226
+ readonly code:'ENROLLMENT_READ_FAILED';
227
+ readonly clientId:number;
228
+ readonly source:'rest'|'gwt-rpc';
229
+ readonly phase:'preflight'|'reconciliation';
230
+ /** false only when this workflow has not attempted a server write. */
231
+ readonly committed:false|'unknown';
232
+ constructor(options:{clientId:number;source:'rest'|'gwt-rpc';writeAttempted:boolean;cause?:unknown});
233
+ }
186
234
  /** Tenant-configured. Retain stable keys and one durable store for all cooperating writers.
187
235
  * A partial or uncertain write requires reconciliation; no automatic write replay occurs. */
188
236
  export function createEnrollmentWorkflow(options:EnrollmentWorkflowOptions):EnrollmentWorkflow;
@@ -1,6 +1,7 @@
1
1
  import {apiScope} from './connection.mjs';
2
2
  import {validateBirthDate} from './date-of-birth.mjs';
3
3
  import {expectedExitDetails} from './exit-details.mjs';
4
+ import {createEntryCorrector} from './entry-correction.mjs';
4
5
  import {createHash} from 'node:crypto';
5
6
  import {readProgramCatalog,encodeAnswers,encodeNewSubassessmentRow,toItems,parseRestInstant} from '../dist/esm/index.js';
6
7
 
@@ -11,6 +12,11 @@ const canonical=v=>JSON.stringify(v,(_,x)=>object(x)?Object.fromEntries(Object.k
11
12
  const hash=v=>createHash('sha256').update(canonical(v)).digest('hex');
12
13
  const fail=message=>{throw new EnrollmentWorkflowError(message);};
13
14
  export class EnrollmentWorkflowError extends Error {constructor(reason,options){super(`Enrollment workflow: ${reason}`,options);this.name='EnrollmentWorkflowError';this.reason=reason;if(options?.step!==undefined)this.step=options.step;}}
15
+ export class EnrollmentReadError extends EnrollmentWorkflowError {
16
+ constructor({clientId,source,writeAttempted,cause}){
17
+ super(cause instanceof EnrollmentWorkflowError?cause.reason:'enrollment list unavailable',{step:'enrollment-read',cause});this.name='EnrollmentReadError';this.code='ENROLLMENT_READ_FAILED';this.clientId=clientId;this.source=source;this.phase=writeAttempted?'reconciliation':'preflight';this.committed=writeAttempted?'unknown':false;
18
+ }
19
+ }
14
20
  function date(value){
15
21
  if(typeof value!=='string'||!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.000)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)||!Number.isFinite(Date.parse(value))||new Date(value.slice(0,19)+'Z').toISOString().slice(0,19)!==value.slice(0,19))fail('timezone-qualified whole-second date required');
16
22
  return new Date(value).toISOString();
@@ -78,14 +84,19 @@ function compile(definition,input,{yesNoWrites,writeLabel}={}){
78
84
  }
79
85
  return {scalar:scalar.filter(q=>q.virt_field_name!=='SVPPROFDOB'),yesNo,mandatory,dateOfBirth:birthDate?.answer_value,rows:compiledRows,ends:compiledEnds,carry,excluded};
80
86
  }
81
- async function enrollments(api,clientId){
87
+ async function enrollments(api,clientId,{enrollmentSession,writeAttempted=false}={}){
88
+ const gwt=typeof enrollmentSession?.listEnrollments==='function';
89
+ try{
82
90
  const all=[],ids=new Set();let count;
83
91
  for(let page=1;page<=100;page++){
84
- let result;try{result=await api.enrollments.listForClient({path:{clientId},query:{perPage:100,startPage:page,total_count:true}});}catch(e){if(page===1&&e.status===404)return [];throw e;}
92
+ let result;
93
+ if(gwt){result=await enrollmentSession.listEnrollments({clientId});if(result?.source!=='gwt-rpc'||result.tenant!==api.tenant||result.clientId!==clientId)fail('enrollment list context mismatch');}
94
+ else try{result=await api.enrollments.listForClient({path:{clientId},query:{perPage:100,startPage:page,total_count:true}});}catch(e){if(page===1&&e.status===404)return [];throw e;}
85
95
  if(!Number.isSafeInteger(result?.count)||result.count<0||!Array.isArray(result.values)||count!==undefined&&count!==result.count)fail('enrollment count unavailable or changed');count=result.count;
86
- for(const row of result.values){if(!positive(row.enrollmentId)||row.clientId!==clientId||!positive(row.enrollmentProvider)||typeof row.type!=='string'||!row.type||!Number.isFinite(restDate(row.projectStartDate))||typeof row.isActive!=='boolean'||ids.has(row.enrollmentId))fail('invalid enrollment list');ids.add(row.enrollmentId);all.push(row);}
87
- if(all.length===count)return all;if(all.length>count||!result.values.length)fail('incomplete enrollment list');
96
+ for(const row of result.values){if(!object(row)||!positive(row.enrollmentId)||row.clientId!==clientId||!positive(row.enrollmentProvider)||typeof row.type!=='string'||!row.type||!Number.isFinite(restDate(row.projectStartDate))||typeof row.isActive!=='boolean'||ids.has(row.enrollmentId)||(gwt||row.projectExitDate!==undefined)&&row.projectExitDate!==null&&!Number.isFinite(restDate(row.projectExitDate)))fail('invalid enrollment list');ids.add(row.enrollmentId);all.push(row);}
97
+ if(all.length===count)return all;if(gwt||all.length>count||!result.values.length)fail('incomplete enrollment list');
88
98
  }fail('enrollment list exceeds limit');
99
+ }catch(cause){throw new EnrollmentReadError({clientId,source:gwt?'gwt-rpc':'rest',writeAttempted,cause});}
89
100
  }
90
101
 
91
102
  /** Explicit orchestration for an existing client. All writers must share the durable store.
@@ -118,11 +129,17 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
118
129
  };
119
130
  const scope=clientId=>JSON.stringify([tenantScope,'enrollment-workflow',clientId]);
120
131
  const reservationScope=clientId=>JSON.stringify([tenantScope,'household-workflow-reservation',clientId]);
132
+ const correctEntry=createEntryCorrector({api,store,enrollmentSession,writeLabel,discover,scope,reservationScope,makeError:(reason,options)=>new EnrollmentWorkflowError(reason,options)});
121
133
  const run=async(kind,raw,internal={})=>{
122
- fields(raw,kind==='entry'?['key','clientId','providerId','type','entryDate','assessment']:['key','enrollmentKey','clientId',kind==='exit'?'exitDate':'reviewDate','assessment']);
134
+ const standaloneExit=kind==='exit'&&internal.standaloneExit===true;
135
+ fields(raw,kind==='entry'?['key','clientId','providerId','type','entryDate','assessment']:['key','enrollmentKey','clientId',kind==='exit'?'exitDate':'reviewDate','assessment',...(standaloneExit?['destination','reasonForLeaving','destinationDetails','reasonForLeavingOther']:[])]);
123
136
  const input=structuredClone(raw);key(input.key);if(!positive(input.clientId))fail('client ID required');
124
137
  if(kind==='entry'){if(!positive(input.providerId)||!entryExitTypes.includes(input.type))fail('program and supported Entry/Exit type required');input.entryDate=date(input.entryDate);}else{key(input.enrollmentKey);if(kind==='addition')input.entryDate=date(input.entryDate);else if(kind==='exit')input.exitDate=date(input.exitDate);else input.reviewDate=date(input.reviewDate);}
125
138
  if(!object(input.assessment))fail('assessment plan required');
139
+ if(standaloneExit&&(typeof enrollmentSession?.readEnrollment!=='function'||typeof exitUi?.validateExit!=='function'||typeof exitUi?.saveExit!=='function'))fail('exit UI and authoritative enrollment session required');
140
+ if(standaloneExit&&['destination','reasonForLeaving'].some(field=>typeof input[field]!=='string'||!input[field].trim()))fail('destination and reason for leaving required');
141
+ const exitDetails=standaloneExit?expectedExitDetails(input):null;
142
+ if(standaloneExit&&(exitDetails.destinationDetails||exitDetails.reasonForLeavingOther)&&typeof exitUi.readExitDetails!=='function')fail('authoritative exit details reader required');
126
143
  const locked=internal.locked?async(_scope,work)=>work():store.withLock.bind(store);
127
144
  return locked(scope(input.clientId),async()=>{
128
145
  const reservations=await store.withLock(reservationScope(input.clientId),()=>store.read(reservationScope(input.clientId)));
@@ -137,6 +154,27 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
137
154
  if(kind!=='entry'&&!entry)fail('verified enrollment workflow required');
138
155
  const context=kind==='entry'?input:kind==='addition'?{...entry.context,entryDate:input.entryDate}:entry.context;
139
156
  if(kind!=='entry'&&Date.parse(input.exitDate??input.reviewDate)<Date.parse(context.entryDate))fail('review precedes entry');
157
+ const exitTarget=standaloneExit?{clientId:input.clientId,enrollmentId:entry.enrollmentId}:null;
158
+ const inspectStandaloneExit=async()=>{
159
+ const snapshot=await enrollmentSession.readEnrollment(exitTarget);
160
+ if(snapshot?.source!=='gwt-rpc'||snapshot.tenant!==api.tenant||snapshot.clientId!==input.clientId||snapshot.enrollmentId!==entry.enrollmentId||snapshot.providerId!==context.providerId||snapshot.enrollmentType!==context.type||restDate(snapshot.projectStartDate)!==restDate(context.entryDate))fail('individual enrollment context mismatch');
161
+ if(!Array.isArray(snapshot.members)||snapshot.members.length!==1||snapshot.members[0].clientId!==input.clientId||snapshot.members[0].enrollmentId!==entry.enrollmentId)fail('linked household enrollment requires exitHousehold');
162
+ if(!Array.isArray(snapshot.reviews)||snapshot.reviews.some(r=>!Number.isFinite(restDate(r.reviewDate))||restDate(r.reviewDate)>restDate(input.exitDate)))fail('exit precedes an existing review');
163
+ if(!Array.isArray(snapshot.exits)||snapshot.exits.length!==1)fail('authoritative individual exit read required');
164
+ const exit=snapshot.exits[0];
165
+ if(exit.clientId!==input.clientId||exit.enrollmentId!==entry.enrollmentId||canonical(exit.clientIds)!==canonical([input.clientId])||canonical(exit.enrollmentIds)!==canonical([entry.enrollmentId]))fail('individual exit membership mismatch');
166
+ if(exit.projectExitDate===null){
167
+ if([exit.destination,exit.reasonForLeaving,exit.exitSubgroupId].some(v=>v!==null))fail('incomplete existing exit metadata');
168
+ return {found:false};
169
+ }
170
+ const label=value=>typeof value==='string'?value.replace(/\s*\(HUD\)\s*$/,'').trim():value;
171
+ if(restDate(exit.projectExitDate)!==restDate(input.exitDate)||label(exit.destination)!==label(input.destination)||label(exit.reasonForLeaving)!==label(input.reasonForLeaving)||exit.exitSubgroupId!==null&&(typeof exit.exitSubgroupId!=='string'||!exit.exitSubgroupId))fail('existing exit conflicts with requested exit');
172
+ if(record&&Object.hasOwn(record,'exitSubgroupId')&&record.exitSubgroupId!==exit.exitSubgroupId)fail('exit identity changed');
173
+ if(record?.steps.exit?.state==='verified'&&record.steps.exit.value.exitSubgroupId!==exit.exitSubgroupId)fail('exit identity changed');
174
+ if(typeof exitUi.readExitDetails==='function'&&canonical(await exitUi.readExitDetails(exitTarget))!==canonical(exitDetails))fail('stored exit details conflict with requested exit');
175
+ return {found:true,value:exit};
176
+ };
177
+ const exitInput=standaloneExit?{...exitTarget,clientIds:[input.clientId],exitDate:input.exitDate,destination:input.destination,reasonForLeaving:input.reasonForLeaving,...(input.destinationDetails?{destinationDetails:input.destinationDetails}:{}),...(input.reasonForLeavingOther?{reasonForLeavingOther:input.reasonForLeavingOther}:{})}:null;
140
178
  const discovered=await discover({clientId:input.clientId,providerId:context.providerId,type:context.type,entryDate:kind==='entry'?input.entryDate:(input.exitDate??input.reviewDate)},kind==='exit');
141
179
  const 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}});
142
180
  if((record&&record.schemaHash!==schemaHash)||(entry&&entry.schemaHash!==schemaHash))fail('program assessment configuration changed');
@@ -180,9 +218,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
180
218
  const readSaved=async()=>questions(await api.assessments.get({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:new Date(Date.parse(at)+1000).toISOString()}}));
181
219
  const initial=await read();
182
220
  if(expectedHead===input.clientId&&headQuestion)checkHeadAnswer(proposedHead?.answer_value??initial.find(q=>q.virt_field_name==='HUD_RELATIONTOHOH')?.answer_value);
183
- for(const field of plan.carry.filter(field=>field!=='SVPPROFDOB')){const q=initial.find(q=>q.virt_field_name===field);if(!q||(q.question_type==='sub_assessment'?!q.sub_assessment_answer_list?.length:!q.answer_value))fail('carry-forward field is empty');}
221
+ for(const field of plan.carry.filter(field=>field!=='SVPPROFDOB')){if(kind==='entry'&&record?.correctedScalars?.[field])continue;const q=initial.find(q=>q.virt_field_name===field);if(!q||(q.question_type==='sub_assessment'?!q.sub_assessment_answer_list?.length:!q.answer_value))fail('carry-forward field is empty');}
184
222
  // Resend mandatory answers with the standard yes/no write. A blank write must not hide a recorded value.
185
223
  const mandatory=plan.mandatory.map(m=>{
224
+ const corrected=kind==='entry'?record?.correctedScalars?.[m.virt_field_name]:undefined;
225
+ if(corrected)return {...m,expected:corrected.answer_value};
186
226
  const recorded=initial.find(q=>q.virt_field_name===m.virt_field_name)?.answer_value;
187
227
  if(m.carry){
188
228
  const question=definition.questions.find(q=>q.fieldName===m.virt_field_name),options=question.options.filter(o=>String(o.id)===String(recorded));
@@ -196,6 +236,10 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
196
236
  });
197
237
  // Validate closure targets before creating enrollment/review or submitting any answers.
198
238
  const current=await readCurrent();
239
+ if(kind==='entry'&&record?.correctedScalars){
240
+ const saved=await readSaved();
241
+ for(const q of Object.values(record.correctedScalars)){const actual=saved.find(r=>r.virt_field_name===q.virt_field_name);if(!actual||!sameAnswer(q,actual.answer_value)||q.answer_value!==''&&restDate(actual.date_effective)!==restDate(at))fail('verified entry correction changed');}
242
+ }
199
243
  const currentHead=current.find(q=>q.virt_field_name==='HUD_RELATIONTOHOH');
200
244
  // A fresh entry may establish a different relationship from a prior enrollment.
201
245
  if(kind!=='entry'&&currentHead?.answer_value!==null&&currentHead?.answer_value!==undefined&&currentHead.answer_value!=='')checkHeadAnswer(currentHead.answer_value);
@@ -207,6 +251,10 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
207
251
  if(restDate(end.endDate)<restDate(start[0].answer_value))fail('end date precedes record start');
208
252
  if(through[0].answer_value&&restDate(through[0].answer_value)!==restDate(end.endDate))fail('record already has a different end date');
209
253
  }
254
+ if(standaloneExit){
255
+ const observed=await inspectStandaloneExit();
256
+ if(!observed.found&&!record?.steps.exit)await exitUi.validateExit(exitInput);
257
+ }
210
258
  if(internal.preflight)return {assessmentCode:code,...(internal.pinSchema?{schemaHash}:{}),...(exitSchemaHash?{exitSchemaHash}:{})};
211
259
  const wasVerified=record?.stage==='verified';
212
260
  if(!record){record={version:1,key:input.key,kind,intentHash,schemaHash,...(exitSchemaHash?{exitSchemaHash}:{}),context:{clientId:input.clientId,providerId:context.providerId,type:context.type,entryDate:context.entryDate},assessmentCode:code,...(yesNoWrites==='standard'?{yesNoWrites}:{}),stage:'pending',steps:{},rows:{},ends:plan.ends};records.push(record);}
@@ -229,8 +277,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
229
277
  };
230
278
  const findEnrollment=async()=>{
231
279
  const knownId=record.enrollmentId??record.steps.enrollment?.value??entry?.enrollmentId;
232
- if(internal.findEnrollment)return internal.findEnrollment({knownId,context});
233
- const list=await enrollments(api,input.clientId),found=list.filter(e=>e.enrollmentProvider===context.providerId&&e.type===context.type&&restDate(e.projectStartDate)===restDate(context.entryDate)&&e.isActive!==false);
280
+ if(internal.findEnrollment)return internal.findEnrollment({knownId,context,writeAttempted:Object.keys(record.steps).length>0});
281
+ if(standaloneExit){await inspectStandaloneExit();return {found:true,value:entry.enrollmentId};}
282
+ const list=await enrollments(api,input.clientId,{enrollmentSession,writeAttempted:Object.keys(record.steps).length>0}),matching=list.filter(e=>e.enrollmentProvider===context.providerId&&e.type===context.type&&restDate(e.projectStartDate)===restDate(context.entryDate)&&e.isActive!==false);
283
+ const open=matching.filter(e=>e.projectExitDate==null);if(open.length>1)fail('duplicate matching enrollments');
284
+ const found=knownId?matching.filter(e=>e.enrollmentId===knownId):open;
234
285
  if(found.length>1)fail('duplicate matching enrollments');
235
286
  const id=record.enrollmentId??record.steps.enrollment?.value??entry?.enrollmentId;if(id&&(!found[0]||found[0].enrollmentId!==id))fail('saved enrollment missing or changed');
236
287
  return {found:found.length===1,value:found[0]?.enrollmentId};
@@ -244,9 +295,8 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
244
295
  const matches=review.snapshot.reviews.filter(r=>r.type==='Update'&&restDate(r.reviewDate)===restDate(at)&&r.clientIds.includes(input.clientId));if(matches.length!==1||record.reviewId&&record.reviewId!==matches[0].reviewId)fail('interim did not verify');record.reviewId=matches[0].reviewId;await persist();
245
296
  }
246
297
  if(kind==='exit'){
247
- if(typeof internal.readExit!=='function')fail('household exit verification required');
248
- const exit=await internal.readExit({clientId:input.clientId,enrollmentId:record.enrollmentId});
249
- if(record.exitSubgroupId&&record.exitSubgroupId!==exit.exitSubgroupId)fail('exit identity changed');
298
+ const exit=standaloneExit?await step('exit',inspectStandaloneExit,()=>exitUi.saveExit(exitInput)):await internal.readExit({clientId:input.clientId,enrollmentId:record.enrollmentId});
299
+ if(Object.hasOwn(record,'exitSubgroupId')&&record.exitSubgroupId!==exit.exitSubgroupId)fail('exit identity changed');
250
300
  record.projectExitDate=exit.projectExitDate;record.exitSubgroupId=exit.exitSubgroupId;await persist();
251
301
  }
252
302
  for(const end of plan.ends){
@@ -270,12 +320,12 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
270
320
  },()=>api.subassessments.setEndDate({clientId:input.clientId,assessmentCode:code,effectiveDate:at,parentField:end.parentField,recordsetId:end.recordsetId,endDateField:end.endDateField,endDate:end.endDate}));
271
321
  }
272
322
  if(plan.scalar.length)await step('scalars',async()=>{
273
- const source=await read();return {found:plan.scalar.every(q=>{const r=source.find(r=>r.virt_field_name===q.virt_field_name);return r&&sameAnswer(q,r.answer_value)&&restDate(r.date_effective)===restDate(at);}),value:plan.scalar.map(q=>q.virt_field_name)};
323
+ const source=kind==='entry'&&record.correctedScalars?await readSaved():await read();return {found:plan.scalar.every(original=>{const q=kind==='entry'?record.correctedScalars?.[original.virt_field_name]??original:original,r=source.find(r=>r.virt_field_name===q.virt_field_name);return r&&sameAnswer(q,r.answer_value)&&(q.answer_value===''||restDate(r.date_effective)===restDate(at));}),value:plan.scalar.map(q=>q.virt_field_name)};
274
324
  },()=>api.assessmentsMobile.addAnswer({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:at},body:plan.scalar}));
275
325
  // Standard writes update existing dated rows and change only the answers sent.
276
326
  if(plan.yesNo.length){const body=[...plan.yesNo,...mandatory.map(({expected,...q})=>q)],expected=[...plan.yesNo,...mandatory.map(({expected,...q})=>({...q,answer_value:expected}))];
277
327
  await step('yesno',async()=>{
278
- const source=await readSaved();return {found:expected.every(q=>{const r=source.find(r=>r.virt_field_name===q.virt_field_name);return r&&sameAnswer(q,r.answer_value)&&restDate(r.date_effective)===restDate(at);}),value:plan.yesNo.map(q=>q.virt_field_name)};
328
+ const source=await readSaved();return {found:expected.every(original=>{const q=kind==='entry'?record.correctedScalars?.[original.virt_field_name]??original:original,r=source.find(r=>r.virt_field_name===q.virt_field_name);return r&&sameAnswer(q,r.answer_value)&&(q.answer_value===''||restDate(r.date_effective)===restDate(at));}),value:plan.yesNo.map(q=>q.virt_field_name)};
279
329
  },()=>api.assessments.create({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:at},body}));
280
330
  }
281
331
  // Group rows into one creation request. Partial success blocks resubmission until all rows reconcile.
@@ -284,8 +334,8 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
284
334
  for(const row of plan.rows){
285
335
  const parent=source.find(q=>q.virt_field_name===row.parentField);if(!Array.isArray(parent?.sub_assessment_answer_list))fail('subassessment read missing');
286
336
  const known=record.steps.rows?.value?.[row.key];
287
- const expected=row.encoded.map(q=>{const overrides=records.flatMap(r=>(r.ends??[]).filter(e=>e.recordsetId===known&&r.steps['end:'+e.recordsetId]?.state==='verified'&&e.endDateField===q.virt_field_name));if(overrides.length>1)fail('multiple end-date overrides');return overrides.length?{...q,answer_value:overrides[0].endDate}:q;});
288
- const candidates=parent.sub_assessment_answer_list.filter(r=>(!known||r.recordset_id===known)&&expected.every(q=>{const fields=r.sub_assessment_list?.filter(c=>c.virt_field_name===q.virt_field_name)??[];return fields.length===1&&fields[0].question_type===q.question_type&&sameAnswer(q,fields[0].answer_value);}));
337
+ const expected=row.encoded.map(original=>{const q=kind==='entry'?record.correctedRows?.[row.parentField]?.[known]?.[original.virt_field_name]??original:original;const overrides=records.flatMap(r=>(r.ends??[]).filter(e=>e.recordsetId===known&&r.steps['end:'+e.recordsetId]?.state==='verified'&&e.endDateField===q.virt_field_name));if(overrides.length>1)fail('multiple end-date overrides');return overrides.length?{...q,answer_value:overrides[0].endDate}:q;});
338
+ const candidates=parent.sub_assessment_answer_list.filter(r=>(!known||r.recordset_id===known)&&expected.every(q=>{const fields=r.sub_assessment_list?.filter(c=>c.virt_field_name===q.virt_field_name)??[];return fields.length===0&&q.answer_value===''&&record.correctedRows?.[row.parentField]?.[known]?.[q.virt_field_name]||fields.length===1&&fields[0].question_type===q.question_type&&sameAnswer(q,fields[0].answer_value);}));
289
339
  if(candidates.length>1)fail('duplicate matching subassessment rows');if(candidates.length){if(!positive(candidates[0].recordset_id)||matchedIds.has(candidates[0].recordset_id))fail('invalid or reused subassessment ID');matchedIds.add(candidates[0].recordset_id);matched[row.key]=candidates[0].recordset_id;}else missing.push(row);
290
340
  }
291
341
  return {found:missing.length===0,value:matched,missing};
@@ -313,6 +363,82 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
313
363
  const householdScope=headClientId=>JSON.stringify([tenantScope,'household-enrollment-workflow',headClientId]);
314
364
  const memberKey=(key,clientId)=>'household:'+hash({key,clientId});
315
365
  const withClients=(ids,work,index=0)=>index===ids.length?work():store.withLock(scope(ids[index]),()=>withClients(ids,work,index+1));
366
+ const adopt=async(household,raw)=>{
367
+ fields(raw,household?['key','householdId','headClientId','members']:['key','clientId','enrollmentId']);
368
+ const input=structuredClone(raw);key(input.key);
369
+ if(typeof enrollmentSession?.readEnrollment!=='function')fail('authoritative enrollment session required for adoption');
370
+ const targets=household?input.members:[{clientId:input.clientId,enrollmentId:input.enrollmentId}];
371
+ if(!Array.isArray(targets)||!targets.length)fail('explicit enrollment targets required');
372
+ for(const target of targets){fields(target,['clientId','enrollmentId']);if(!positive(target.clientId)||!positive(target.enrollmentId))fail('explicit enrollment targets required');}
373
+ targets.sort((a,b)=>a.clientId-b.clientId);const ids=targets.map(t=>t.clientId);
374
+ if(new Set(ids).size!==ids.length||new Set(targets.map(t=>t.enrollmentId)).size!==targets.length)fail('duplicate adoption targets');
375
+ 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');
376
+ const owner=hash({adoption:true,input}),intentHash=hash(input);
377
+ return withClients(ids,async()=>{
378
+ const locked=household?work=>store.withLock(householdScope(input.headClientId),work):work=>work();
379
+ return locked(async()=>{
380
+ const groups=household?await store.read(householdScope(input.headClientId)):[];
381
+ if(!Array.isArray(groups)||groups.some(r=>r.stage!=='verified'&&r.key!==input.key))fail('another unfinished household workflow must be reconciled first');
382
+ const groupMatches=groups.filter(r=>r.key===input.key);if(groupMatches.length>1)fail('duplicate household journal keys');let group=groupMatches[0];
383
+ if(group&&(!group.adopted||group.intentHash!==intentHash))fail('adoption key conflicts with previous intent');
384
+ const journals=new Map(),prior=new Map();
385
+ for(const target of targets){
386
+ const reservations=await store.withLock(reservationScope(target.clientId),()=>store.read(reservationScope(target.clientId)));
387
+ if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'&&r.owner!==owner))fail('unfinished household workflow must be reconciled first');
388
+ const records=await store.read(scope(target.clientId)),member=household?memberKey(input.key,target.clientId):input.key;
389
+ if(!Array.isArray(records)||records.some(r=>r.version!==1||!object(r.steps)))fail('invalid workflow journal');
390
+ if(records.some(r=>r.stage!=='verified'&&r.key!==member))fail('another unfinished workflow must be reconciled first');
391
+ if(records.some(r=>r.kind==='entry'&&r.enrollmentId===target.enrollmentId&&r.key!==member))fail('enrollment already journaled; use its existing entry key');
392
+ const matches=records.filter(r=>r.key===member);if(matches.length>1)fail('duplicate journal keys');
393
+ if(matches[0]&&(!matches[0].adopted||matches[0].intentHash!==intentHash))fail('adoption key conflicts with previous intent');
394
+ journals.set(target.clientId,records);prior.set(target.clientId,matches[0]);
395
+ }
396
+ const snapshots=[];
397
+ for(const target of targets){
398
+ const s=await enrollmentSession.readEnrollment(target);
399
+ if(s?.source!=='gwt-rpc'||s.tenant!==api.tenant||s.clientId!==target.clientId||s.enrollmentId!==target.enrollmentId||!positive(s.providerId)||!entryExitTypes.includes(s.enrollmentType)||!Number.isFinite(restDate(s.projectStartDate)))fail('adoption enrollment context mismatch');
400
+ if(!Array.isArray(s.members)||canonical([...s.members].sort((a,b)=>a.clientId-b.clientId))!==canonical(targets))fail(household?'adoption requires the complete linked enrollment cohort':'linked household enrollment requires adoptHouseholdEnrollment');
401
+ const exits=s.exits?.filter(e=>e.clientId===target.clientId&&e.enrollmentId===target.enrollmentId);
402
+ if(exits?.length!==1||exits[0].projectExitDate!==null&&!Number.isFinite(restDate(exits[0].projectExitDate)))fail('authoritative adoption exit date required');
403
+ const start=restDate(s.projectStartDate),end=exits[0].projectExitDate===null?Infinity:restDate(exits[0].projectExitDate);if(end<start)fail('adoption exit precedes entry');
404
+ snapshots.push({target,s,start,end});
405
+ }
406
+ const head=snapshots.find(r=>r.target.clientId===(household?input.headClientId:input.clientId));
407
+ 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');
408
+ if(household){
409
+ const found=toItems(await api.households.get({path:{householdId:input.householdId}}));
410
+ if(found.length!==1||found[0].householdId!==input.householdId||found[0].isActive===false||!Array.isArray(found[0].clients))fail('active household required for adoption');
411
+ const members=found[0].clients,heads=members.filter(m=>m.headofhouseholdStatus&&m.isActive!==false&&!m.dateLeft);
412
+ if(heads.length!==1||heads[0].clientId!==input.headClientId)fail('adoption household head mismatch');
413
+ for(const r of snapshots){const membersForClient=members.filter(m=>m.clientId===r.target.clientId);if(membersForClient.length!==1||membersForClient[0].isActive===false||membersForClient[0].dateLeft||!Number.isFinite(restDate(membersForClient[0].dateJoined))||restDate(membersForClient[0].dateJoined)>r.start||!Array.isArray(r.s.householdClientIds)||ids.some(id=>!r.s.householdClientIds.includes(id)))fail('adoption household membership mismatch');}
414
+ }
415
+ const prepared=[];
416
+ for(const r of snapshots){
417
+ const context={clientId:r.target.clientId,providerId:r.s.providerId,type:r.s.enrollmentType,entryDate:new Date(r.start).toISOString()};
418
+ 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}});
419
+ const headQuestion=discovered.entry.definition.questions.find(q=>q.fieldName==='HUD_RELATIONTOHOH');
420
+ if(headQuestion){
421
+ 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}}));
422
+ const answer=answers.find(q=>q.virt_field_name==='HUD_RELATIONTOHOH')?.answer_value;
423
+ 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');
424
+ }
425
+ const existing=prior.get(r.target.clientId);
426
+ if(existing&&(existing.enrollmentId!==r.target.enrollmentId||canonical(existing.context)!==canonical(context)||existing.schemaHash!==schemaHash))fail('adopted enrollment identity or configuration changed');
427
+ prepared.push(existing??{version:1,key:household?memberKey(input.key,r.target.clientId):input.key,kind:'entry',adopted:true,intentHash,schemaHash,context,enrollmentId:r.target.enrollmentId,assessmentCode:discovered.entry.assessment.code,stage:'verified',steps:{enrollment:{state:'verified',value:r.target.enrollmentId}},rows:{},ends:[]});
428
+ }
429
+ const wasVerified=household?group?.stage==='verified':prior.get(input.clientId)?.stage==='verified';
430
+ 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)}));
431
+ if(household){
432
+ 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);}
433
+ await store.write(householdScope(input.headClientId),structuredClone(groups));
434
+ for(const clientId of ids)await store.withLock(reservationScope(clientId),()=>store.write(reservationScope(clientId),[{owner,stage:'pending'}]));
435
+ }
436
+ 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)));}
437
+ 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};}
438
+ return results[0];
439
+ });
440
+ });
441
+ };
316
442
  const householdRun=async(kind,raw)=>{
317
443
  if(typeof enrollmentSession?.readEnrollment!=='function'||typeof helpers.addHouseholdMembers!=='function'||typeof api.households?.get!=='function')fail('household API and authoritative enrollment session required');
318
444
  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']:[])]);
@@ -344,7 +470,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
344
470
  const base=entry??record;
345
471
  const catalog={};
346
472
  for(const group of [base,...records.filter(r=>r.kind==='addition'&&r.stage==='verified'&&r.enrollmentKey===(entry?.key??input.key))].filter(Boolean)){
347
- for(const clientId of group.clientIds){if(catalog[clientId])fail('duplicate household member enrollment');catalog[clientId]={...group.members[clientId],entryDate:group.context.entryDate,entryKey:group.key};}
473
+ for(const clientId of group.clientIds){if(catalog[clientId])fail('duplicate household member enrollment');catalog[clientId]={...group.members[clientId],entryDate:group.members[clientId]?.effectiveDate??group.context.entryDate,entryKey:group.key};}
348
474
  }
349
475
  if(kind==='addition'){
350
476
  if(typeof memberEntryDatePort?.validateMemberEntryDate!=='function'||typeof memberEntryDatePort?.saveMemberEntryDate!=='function')fail('member entry date port required');
@@ -404,11 +530,21 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
404
530
  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);}
405
531
  const persist=()=>store.write(householdScope(input.headClientId),structuredClone(records));await persist();
406
532
  for(const clientId of cohortIds)await store.withLock(reservationScope(clientId),()=>store.write(reservationScope(clientId),[{owner,stage:'pending'}]));
407
- const findMember=clientId=>async({knownId})=>{
533
+ const writeAttempted=async()=>{
534
+ if(Object.keys(record.members).length>0||record.attachmentAttempted===true)return true;
535
+ // The head's creation can commit before its result reaches the household journal.
536
+ // Its per-client attempt marker remains authoritative on the next household retry.
537
+ for(const clientId of ids){
538
+ const entries=await store.read(scope(clientId));if(!Array.isArray(entries))fail('invalid workflow journal');
539
+ if(entries.some(r=>r.key===memberKey(input.key,clientId)&&r.steps&&Object.keys(r.steps).length>0))return true;
540
+ }
541
+ return false;
542
+ };
543
+ const findMember=clientId=>async({knownId,writeAttempted:memberWriteAttempted=false})=>{
408
544
  const expected=record.members[clientId]?.enrollmentId??catalog[clientId]?.enrollmentId??knownId;
409
545
  if(expected){if(knownId&&knownId!==expected)fail('household enrollment identity changed');await readTarget({clientId,enrollmentId:expected},{partial:kind==='entry'&&clientId===input.headClientId&&!record.attached});return {found:true,value:expected};}
410
546
  if(clientId!==input.headClientId)fail('member enrollment has not been attached');
411
- const found=(await enrollments(api,clientId)).filter(e=>e.enrollmentProvider===context.providerId&&e.type===context.type&&e.isActive&&restDate(e.projectStartDate)===restDate(context.entryDate));
547
+ const found=(await enrollments(api,clientId,{enrollmentSession,writeAttempted:memberWriteAttempted||await writeAttempted()})).filter(e=>e.enrollmentProvider===context.providerId&&e.type===context.type&&e.isActive&&e.projectExitDate==null&&restDate(e.projectStartDate)===restDate(context.entryDate));
412
548
  if(found.length>1)fail('duplicate matching enrollments');
413
549
  if(found[0])await readTarget({clientId,enrollmentId:found[0].enrollmentId},{partial:!record.attached});
414
550
  return {found:found.length===1,value:found[0]?.enrollmentId};
@@ -432,7 +568,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
432
568
  // Before attachment, an independently matching member enrollment is ambiguous.
433
569
  // Once any attachment was attempted, only the GWT journal can reconcile it.
434
570
  if(!record.attachmentAttempted)for(const clientId of ids.filter(id=>id!==input.headClientId)){
435
- const existing=await enrollments(api,clientId);if(existing.some(e=>e.enrollmentProvider===context.providerId&&e.type===context.type&&e.isActive&&restDate(e.projectStartDate)===restDate(context.entryDate)))fail('member already has an independent matching enrollment');
571
+ const existing=await enrollments(api,clientId,{enrollmentSession,writeAttempted:await writeAttempted()});if(existing.some(e=>e.enrollmentProvider===context.providerId&&e.type===context.type&&e.isActive&&e.projectExitDate==null&&restDate(e.projectStartDate)===restDate(context.entryDate)))fail('member already has an independent matching enrollment');
436
572
  }
437
573
  const head=input.members.find(m=>m.clientId===input.headClientId);
438
574
  const result=await run('entry',toMember(head),{locked:true,owner,headClientId:input.headClientId,findEnrollment:findMember(head.clientId)});record.members[head.clientId]=result;await persist();
@@ -453,7 +589,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
453
589
  if(!record.anchor)fail('open household enrollment required to add a member');await persist();
454
590
  }
455
591
  if(!record.attachmentAttempted)for(const clientId of ids){
456
- const existing=await enrollments(api,clientId);if(existing.some(e=>e.enrollmentProvider===context.providerId&&e.type===context.type&&e.isActive))fail('additional member already has an independent enrollment');
592
+ const existing=await enrollments(api,clientId,{enrollmentSession,writeAttempted:await writeAttempted()});if(existing.some(e=>e.enrollmentProvider===context.providerId&&e.type===context.type&&e.isActive&&e.projectExitDate==null))fail('additional member already has an independent enrollment');
457
593
  }
458
594
  record.attachmentAttempted=true;await persist();
459
595
  const attached=await helpers.addHouseholdMembers({...record.anchor,memberClientIds:ids});
@@ -553,6 +689,8 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
553
689
  }));
554
690
  };
555
691
  return {
692
+ correctEntry,
693
+ adoptEnrollment:input=>adopt(false,input),adoptHouseholdEnrollment:input=>adopt(true,input),
556
694
  addHouseholdMembers:input=>householdRun('addition',input),
557
695
  createHouseholdEnrollment:input=>householdRun('entry',input),createHouseholdInterim:input=>householdRun('interim',input),exitHousehold:input=>householdRun('exit',input),
558
696
  discoverExit:input=>discover(input,true),
@@ -565,7 +703,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
565
703
  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]??{})}))};
566
704
  });
567
705
  },
568
- discover,createEnrollment:input=>run('entry',input),createInterim:input=>run('interim',input),
706
+ discover,createEnrollment:input=>run('entry',input),createInterim:input=>run('interim',input),exitEnrollment:input=>run('exit',input,{standaloneExit:true}),
569
707
  async getStatus(input){
570
708
  fields(input,['clientId','key']);if(!positive(input.clientId))fail('client ID required');key(input.key);
571
709
  return store.withLock(scope(input.clientId),async()=>{
@@ -0,0 +1,164 @@
1
+ import {createHash} from 'node:crypto';
2
+ import {encodeAnswers,parseRestInstant,toItems} from '../dist/esm/index.js';
3
+
4
+ const object=v=>v!==null&&typeof v==='object'&&!Array.isArray(v);
5
+ const canonical=v=>JSON.stringify(v,(_,x)=>object(x)?Object.fromEntries(Object.keys(x).sort().map(k=>[k,x[k]])):x);
6
+ const hash=v=>createHash('sha256').update(canonical(v)).digest('hex');
7
+ const positive=v=>Number.isSafeInteger(v)&&v>0;
8
+ const scalarTypes=new Set(['lookup','yes_no','date','int','money','textbox','textarea']);
9
+
10
+ /** Internal correction writer. The workflow supplies its existing client locks and journal scopes. */
11
+ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,discover,scope,reservationScope,makeError}){
12
+ const fail=reason=>{throw makeError(reason);};
13
+ const fields=(v,allowed)=>{if(!object(v)||Object.keys(v).some(k=>!allowed.includes(k)))fail('unknown or invalid correction fields');};
14
+ const key=v=>{if(typeof v!=='string'||!v.trim()||v.length>200)fail('stable correction key required');};
15
+ const value=(type,v)=>{
16
+ if(v===null||v===undefined||v==='')return null;
17
+ if(type==='date'){const n=parseRestInstant(v);if(!Number.isFinite(n))fail('invalid stored correction date');return n;}
18
+ if(type==='int'||type==='money'){const n=Number(v);if(!Number.isFinite(n))fail('invalid stored correction number');return n;}
19
+ return String(v);
20
+ };
21
+ const encode=(questions,answers,clear,endpoint)=>{
22
+ if(!object(answers)||!Array.isArray(clear)||new Set(clear).size!==clear.length||clear.some(f=>typeof f!=='string'||Object.hasOwn(answers,f)))fail('invalid or conflicting correction answers and clears');
23
+ const index=new Map(questions.map(q=>[q.fieldName,q]));
24
+ for(const field of [...Object.keys(answers),...clear]){
25
+ const q=index.get(field);
26
+ if(!q||!scalarTypes.has(q.questionType)||endpoint==='mobile'&&q.questionType==='yes_no')fail('unsupported correction field type');
27
+ if(['SVPPROFDOB','HUD_RELATIONTOHOH'].includes(field))fail('DOB and household head corrections require their dedicated workflows');
28
+ }
29
+ const body=encodeAnswers(questions,answers,{endpoint,writeLabel});
30
+ if(body.length!==Object.keys(answers).length)fail('correction answer could not be encoded');
31
+ for(const q of body)if(q.question_type==='date'&&q.answer_value){const day=q.answer_value.slice(0,10);if(!Number.isFinite(parseRestInstant(q.answer_value))||!/^\d{4}-\d{2}-\d{2}$/.test(day)||new Date(day+'T00:00:00Z').toISOString().slice(0,10)!==day)fail('invalid correction date');}
32
+ for(const field of clear){const q=index.get(field);body.push({question_id:q.questionId,question_type:q.questionType,virt_field_name:field,answer_value:''});}
33
+ return body;
34
+ };
35
+ const expected=(questions,body,answers)=>body.map(q=>{
36
+ if(q.question_type!=='lookup'||q.answer_value==='')return q;
37
+ const encoded=encodeAnswers(questions,{[q.virt_field_name]:answers[q.virt_field_name]},{endpoint:'mobile'});
38
+ if(encoded.length!==1)fail('correction lookup could not be resolved');return encoded[0];
39
+ });
40
+ const readQuestions=async(clientId,assessmentCode,at)=>{
41
+ const rows=toItems(await api.assessments.get({path:{clientId,assessmentCode},query:{dateEffective:at}}));
42
+ if(rows.some(q=>!object(q)||typeof q.virt_field_name!=='string')||new Set(rows.map(q=>q.virt_field_name)).size!==rows.length)fail('ambiguous correction assessment read');
43
+ return rows;
44
+ };
45
+ return async raw=>{
46
+ fields(raw,['key','enrollmentKey','clientId','answers','clear','subassessments']);
47
+ const input=structuredClone(raw);key(input.key);key(input.enrollmentKey);
48
+ if(!positive(input.clientId)||!Array.isArray(input.subassessments??[]))fail('invalid correction client or rows');
49
+ if(typeof api.assessments?.create!=='function'||typeof enrollmentSession?.readEnrollment!=='function')fail('standard assessment writes and authoritative enrollment reader required for corrections');
50
+ return store.withLock(scope(input.clientId),async()=>{
51
+ const reservations=await store.withLock(reservationScope(input.clientId),()=>store.read(reservationScope(input.clientId)));
52
+ if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'))fail('unfinished household workflow must be reconciled first');
53
+ const records=await store.read(scope(input.clientId));
54
+ if(!Array.isArray(records)||records.some(r=>!object(r)||r.version!==1||!object(r.steps)))fail('invalid workflow journal');
55
+ if(records.some(r=>r.stage!=='verified'&&r.key!==input.key))fail('another unfinished workflow must be reconciled first');
56
+ const entries=records.filter(r=>r.kind==='entry'&&r.key===input.enrollmentKey&&r.stage==='verified');
57
+ if(entries.length!==1)fail('verified enrollment workflow required');const entry=entries[0];
58
+ const matches=records.filter(r=>r.key===input.key);if(matches.length>1)fail('duplicate correction journal keys');let record=matches[0];
59
+ const intentHash=hash(input);if(record&&(record.kind!=='correction'||record.intentHash!==intentHash))fail('correction key conflicts with previous intent');
60
+ if(!record&&records.some(r=>r.enrollmentId===entry.enrollmentId&&!['entry','correction'].includes(r.kind)))fail('entry correction must precede interim or exit workflows');
61
+ const {providerId,type,entryDate}=entry.context,at=new Date(parseRestInstant(entryDate)).toISOString(),readAt=new Date(parseRestInstant(at)+1000).toISOString();
62
+ const target={clientId:input.clientId,enrollmentId:entry.enrollmentId};
63
+ const verifyEnrollment=async()=>{
64
+ const s=await enrollmentSession.readEnrollment(target);
65
+ if(s?.source!=='gwt-rpc'||s.tenant!==api.tenant||s.clientId!==target.clientId||s.enrollmentId!==target.enrollmentId||s.providerId!==providerId||s.enrollmentType!==type||parseRestInstant(s.projectStartDate)!==parseRestInstant(at)||!Array.isArray(s.members)||s.members.filter(m=>m.clientId===target.clientId&&m.enrollmentId===target.enrollmentId).length!==1)fail('entry correction enrollment context mismatch');
66
+ const exits=s.exits?.filter(e=>e.clientId===target.clientId&&e.enrollmentId===target.enrollmentId);
67
+ if(exits?.length!==1||exits[0].projectExitDate!==null||!Array.isArray(s.reviews)||s.reviews.length)fail('entry correction requires an open enrollment without reviews');
68
+ };
69
+ await verifyEnrollment();
70
+ const discovered=await discover({clientId:input.clientId,providerId,type,entryDate:at});
71
+ const 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}});
72
+ if(schemaHash!==entry.schemaHash||record&&schemaHash!==record.schemaHash)fail('program assessment configuration changed');
73
+ const definition=discovered.entry.definition,code=definition.assessmentCode;
74
+ const snapshot=async()=>{
75
+ const raw=await readQuestions(input.clientId,code,readAt),current=await readQuestions(input.clientId,code,new Date(Math.max(Date.now(),parseRestInstant(readAt))).toISOString());
76
+ const scalars={},rows={};
77
+ for(const q of definition.questions){
78
+ if(['blank','label'].includes(q.questionType))continue;
79
+ if(q.questionType!=='sub_assessment'){
80
+ const actual=raw.find(r=>r.virt_field_name===q.fieldName);if(!actual)fail('correction scalar read missing');
81
+ const date=actual.date_effective?parseRestInstant(actual.date_effective):null;if(date!==null&&!Number.isFinite(date))fail('invalid correction effective date');
82
+ scalars[q.fieldName]={type:q.questionType,value:value(q.questionType,actual.answer_value),date};
83
+ continue;
84
+ }
85
+ const parent=current.find(r=>r.virt_field_name===q.fieldName);if(!parent||!Array.isArray(parent.sub_assessment_answer_list??[]))fail('correction subassessment read missing');
86
+ rows[q.fieldName]={};
87
+ for(const row of parent.sub_assessment_answer_list??[]){
88
+ if(!positive(row.recordset_id)||Object.hasOwn(rows[q.fieldName],row.recordset_id)||!Array.isArray(row.sub_assessment_list))fail('ambiguous correction row');
89
+ const cells={};
90
+ for(const child of q.children){
91
+ if(['label','blank'].includes(child.questionType))continue;
92
+ const found=row.sub_assessment_list.filter(c=>c.virt_field_name===child.fieldName);if(found.length>1||found[0]&&found[0].question_type!==child.questionType)fail('ambiguous correction row cell');
93
+ cells[child.fieldName]={type:child.questionType,value:value(child.questionType,found[0]?.answer_value)};
94
+ }
95
+ if(row.sub_assessment_list.some(c=>!q.children.some(child=>child.fieldName===c.virt_field_name)))fail('correction row metadata incomplete');
96
+ rows[q.fieldName][row.recordset_id]=cells;
97
+ }
98
+ }
99
+ return {raw,scalars,rows};
100
+ };
101
+ const observed=await snapshot();
102
+ const readOverrides=state=>{
103
+ for(const q of Object.values(entry.correctedScalars??{})){
104
+ const actual=state.scalars[q.virt_field_name];if(!actual||actual.value!==value(q.question_type,q.answer_value)||actual.value!==null&&actual.date!==parseRestInstant(at))fail('verified entry correction changed');
105
+ }
106
+ for(const [parent,group]of Object.entries(entry.correctedRows??{}))for(const [id,cells]of Object.entries(group))for(const q of Object.values(cells))if(state.rows[parent]?.[id]?.[q.virt_field_name]?.value!==value(q.question_type,q.answer_value))fail('verified row correction changed');
107
+ };
108
+ const result=outcome=>({key:input.key,enrollmentKey:input.enrollmentKey,outcome,clientId:input.clientId,enrollmentId:entry.enrollmentId,assessmentCode:code,effectiveDate:at,recordsetIds:structuredClone(entry.rows)});
109
+ if(record?.stage==='verified'){readOverrides(observed);return result('already-present');}
110
+ if(!record){
111
+ readOverrides(observed);
112
+ const answers=input.answers??{},clear=input.clear??[];
113
+ const scalarBody=encode(definition.questions,answers,clear,'standalone'),scalarExpected=expected(definition.questions,scalarBody,answers);
114
+ // The standard endpoint requires every mandatory scalar, even if this patch leaves it unchanged.
115
+ if(scalarBody.length)for(const q of definition.questions.filter(q=>q.mandatory&&!['label','blank','sub_assessment'].includes(q.questionType)&&!scalarBody.some(c=>c.virt_field_name===q.fieldName))){
116
+ if(q.fieldName==='SVPPROFDOB'||!scalarTypes.has(q.questionType))fail('unsupported mandatory correction field');
117
+ const prior=observed.raw.find(r=>r.virt_field_name===q.fieldName);
118
+ if(prior?.answer_value===null||prior?.answer_value===undefined||prior?.answer_value==='')fail('mandatory correction field requires an explicit answer or clear');
119
+ let body;
120
+ if(q.questionType==='lookup'){
121
+ const options=q.options.filter(o=>String(o.id)===String(prior.answer_value)),label=options.length===1?writeLabel?.(q,options[0]):undefined;
122
+ if(!label)fail('mandatory correction lookup label unavailable');
123
+ body=[{question_id:q.questionId,question_type:q.questionType,virt_field_name:q.fieldName,answer_value:label}];
124
+ }else body=encodeAnswers([q],{[q.fieldName]:{value:prior.answer_value}},{endpoint:'standalone',writeLabel});
125
+ if(body.length!==1)fail('mandatory correction field could not be encoded');scalarBody.push(body[0]);
126
+ scalarExpected.push({...body[0],answer_value:prior.answer_value});
127
+ }
128
+ const rowPlans=[],ids=new Set();
129
+ for(const patch of input.subassessments??[]){
130
+ fields(patch,['key','parentField','answers','clear']);key(patch.key);
131
+ const id=entry.rows[patch.key],parent=definition.questions.find(q=>q.fieldName===patch.parentField&&q.questionType==='sub_assessment');
132
+ if(!positive(id)||ids.has(id)||!parent||!observed.rows[parent.fieldName]?.[id])fail('correction requires an existing entry row key and matching parent');ids.add(id);
133
+ const cells=encode(parent.children,patch.answers??{},patch.clear??[],'mobile');if(!cells.length)fail('empty row correction');
134
+ rowPlans.push({id,parentField:parent.fieldName,questionId:parent.questionId,cells});
135
+ }
136
+ if(!scalarBody.length&&!rowPlans.length)fail('empty entry correction');
137
+ record={version:1,key:input.key,kind:'correction',enrollmentKey:input.enrollmentKey,intentHash,schemaHash,context:structuredClone(entry.context),enrollmentId:entry.enrollmentId,assessmentCode:code,stage:'pending',steps:{},rows:structuredClone(entry.rows),before:{scalars:observed.scalars,rows:observed.rows},scalarBody,scalarExpected,rowPlans};records.push(record);
138
+ }
139
+ const persist=()=>store.write(scope(input.clientId),structuredClone(records));await persist();
140
+ const inspect=async(candidate,complete=false)=>{
141
+ const state=await snapshot(),want=structuredClone(record.before);
142
+ if(complete||candidate==='scalars'||record.steps.scalars?.state==='verified')for(const q of record.scalarExpected)want.scalars[q.virt_field_name]={type:q.question_type,value:value(q.question_type,q.answer_value),date:parseRestInstant(at)};
143
+ for(const plan of record.rowPlans)if(complete||candidate==='row:'+plan.id||record.steps['row:'+plan.id]?.state==='verified')for(const q of plan.cells)want.rows[plan.parentField][plan.id][q.virt_field_name]={type:q.question_type,value:value(q.question_type,q.answer_value)};
144
+ const scalarsMatch=Object.keys(state.scalars).length===Object.keys(want.scalars).length&&Object.entries(want.scalars).every(([field,e])=>{const a=state.scalars[field];return a&&a.type===e.type&&a.value===e.value&&(e.value===null||a.date===e.date);});
145
+ return scalarsMatch&&canonical(state.rows)===canonical(want.rows);
146
+ };
147
+ const step=async(name,submit)=>{
148
+ if(await inspect(name)){record.steps[name]={state:'verified'};await persist();return;}
149
+ if(record.steps[name])throw makeError('uncertain entry correction requires reconciliation',{step:name});
150
+ if(!await inspect(null))fail('entry changed since correction preflight');
151
+ await verifyEnrollment();record.steps[name]={state:'attempted'};await persist();let cause;
152
+ try{await submit();}catch(e){cause=e;}
153
+ if(!await inspect(name))throw makeError('uncertain entry correction requires reconciliation',{step:name,cause});
154
+ record.steps[name]={state:'verified'};await persist();
155
+ };
156
+ if(record.scalarBody.length)await step('scalars',()=>api.assessments.create({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:at},body:record.scalarBody}));
157
+ for(const plan of record.rowPlans)await step('row:'+plan.id,()=>api.assessmentsMobile.addAnswer({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:at},body:[{question_id:plan.questionId,question_type:'sub_assessment',virt_field_name:plan.parentField,answer_value:'',subAssessmentQuestionAnswers:[{recordset_id:plan.id,isDeleted:false,subAssessments:plan.cells}]}]}));
158
+ if(!await inspect(null,true))fail('entry correction did not verify');await verifyEnrollment();
159
+ entry.correctedScalars??={};for(const q of record.scalarExpected)entry.correctedScalars[q.virt_field_name]=q;
160
+ entry.correctedRows??={};for(const plan of record.rowPlans){entry.correctedRows[plan.parentField]??={};entry.correctedRows[plan.parentField][plan.id]??={};for(const q of plan.cells)entry.correctedRows[plan.parentField][plan.id][q.virt_field_name]=q;}
161
+ record.stage='verified';await persist();return result('verified');
162
+ });
163
+ };
164
+ }
@@ -27,7 +27,7 @@ export interface HouseholdExitUi {
27
27
  /** Reopen the saved member exit and read the persisted conditional fields, then cancel. */
28
28
  readExitDetails(target:HouseholdExitTarget):Promise<HouseholdExitStoredDetails>;
29
29
  validateExit(input:HouseholdExitUiInput):Promise<void>;
30
- /** One save attempt only. Use through the durable exitHousehold workflow. */
30
+ /** One save attempt only. Use through the durable exitEnrollment or exitHousehold workflow. */
31
31
  saveExit(input:HouseholdExitUiInput):Promise<void>;
32
32
  }
33
33
  /** UI port with fresh conditional choices and per-member readback. */
@@ -5,7 +5,7 @@ import {expectedExitDetails,rentalDestination} from './exit-details.mjs';
5
5
 
6
6
  const label=value=>value.replace(/\s*\(HUD\)\s*$/,'').trim();
7
7
  const id=value=>Number.isSafeInteger(value)&&value>0;
8
- /** Low-level UI port. Use through exitHousehold, which journals before save.
8
+ /** Low-level UI port. Use through exitEnrollment or exitHousehold, which journal before save.
9
9
  * This port makes at most one Save click and never retries an uncertain write. */
10
10
  export function createHouseholdExitUi({page}){
11
11
  const connection=pageConnection(page),base=connection.moduleBaseUrl;
@@ -80,7 +80,9 @@ export function createHouseholdExitUi({page}){
80
80
  if((await box.isChecked())!==desired.includes(clientId))throw Error('Exit household selection mismatch');
81
81
  }
82
82
  const selected=await page.locator('[id^="HouseholdsCheckboxTree.householdCheckbox."]:visible input:checked').evaluateAll(es=>es.map(e=>Number(e.parentElement.id.match(/\.client-(\d+)$/)?.[1])).sort((a,b)=>a-b));
83
- if(JSON.stringify(selected)!==JSON.stringify(desired))throw Error('Exit household selection incomplete');
83
+ // A standalone person's form can bind the target without rendering a household tree.
84
+ const implicit=await boxes.count()===0&&snapshot.members.length===1&&snapshot.members[0].clientId===input.clientId&&snapshot.members[0].enrollmentId===input.enrollmentId&&desired.length===1&&desired[0]===input.clientId;
85
+ if(!implicit&&JSON.stringify(selected)!==JSON.stringify(desired))throw Error('Exit household selection incomplete');
84
86
  const parts=await page.evaluate(value=>{const d=new Date(value),local=new Date(d.getFullYear(),d.getMonth(),d.getDate(),d.getHours(),d.getMinutes(),d.getSeconds());if(+d!==+local)throw Error('Ambiguous local exit time');return {Month:String(d.getMonth()+1).padStart(2,'0'),Day:String(d.getDate()).padStart(2,'0'),Year:String(d.getFullYear()),Hour:String(d.getHours()%12||12),Minute:String(d.getMinutes()).padStart(2,'0'),Second:String(d.getSeconds()).padStart(2,'0'),'AM/PM':d.getHours()<12?'AM':'PM'};},input.exitDate);
85
87
  for(const [part,value]of Object.entries(parts)){const field=page.getByLabel(`Exit Date ${part}`,{exact:true});if(['Month','Day','Year'].includes(part))await field.fill(value);else await field.selectOption({label:value});if(await field.inputValue()!==value)throw Error('Exit date did not match');}
86
88
  for(const [field,name]of [['destination','Destination'],['reasonForLeaving','Reason for Leaving']])if(await page.getByLabel(name,{exact:true}).inputValue()!==chosen[field])throw Error('Exit choice did not match');
@@ -18,14 +18,27 @@ export class LoginError extends Error {
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
20
  export interface PlaywrightEnrollmentSnapshot extends GwtEnrollmentSnapshot {providerId:number;enrollmentType:string;exits:readonly GwtEnrollmentExit[];}
21
+ export interface EnrollmentListRow {
22
+ enrollmentId:number;clientId:number;enrollmentProvider:number;type:string;
23
+ projectStartDate:string;projectExitDate:string|null;isActive:boolean;
24
+ }
25
+ export interface EnrollmentListSnapshot {
26
+ source:'gwt-rpc';tenant:string;clientId:number;count:number;values:EnrollmentListRow[];
27
+ }
21
28
  export interface EnrollmentReader {
22
29
  readonly connection?:UiConnection;
23
30
  readEnrollment(target:{clientId:number;enrollmentId:number}):Promise<PlaywrightEnrollmentSnapshot>;
31
+ /** Built-in sessions supply this complete, counted list, including exited entries.
32
+ * Legacy custom readers without it retain REST list behavior. */
33
+ listEnrollments?(target:{clientId:number}):Promise<EnrollmentListSnapshot>;
34
+ }
35
+ export interface EnrollmentCatalogReader extends EnrollmentReader {
36
+ listEnrollments(target:{clientId:number}):Promise<EnrollmentListSnapshot>;
24
37
  }
25
38
  export function openUiSession(options:LoginOptions):Promise<UiSession>;
26
39
  /** The read-only enrollment session shares the page lock with mutation helpers. */
27
- export function openEnrollmentSession(options:LoginOptions&{store:GwtMutationStore & UiMutationStore}):Promise<UiSession&{helpers:GwtEnrollmentHelpers;enrollmentSession:EnrollmentReader;workflows:ClientWorkflows}>;
28
- export function createPlaywrightGwtSession(options:PlaywrightGwtOptions):Omit<GwtEnrollmentSession,'readEnrollment'>&EnrollmentReader;
40
+ export function openEnrollmentSession(options:LoginOptions&{store:GwtMutationStore & UiMutationStore}):Promise<UiSession&{helpers:GwtEnrollmentHelpers;enrollmentSession:EnrollmentCatalogReader;workflows:ClientWorkflows}>;
41
+ export function createPlaywrightGwtSession(options:PlaywrightGwtOptions):Omit<GwtEnrollmentSession,'readEnrollment'>&EnrollmentCatalogReader;
29
42
  export function createPlaywrightEnrollmentHelpers(options:PlaywrightGwtOptions&{store:GwtMutationStore}):GwtEnrollmentHelpers;
30
43
 
31
44
  /** Durable JSON journal. Use a separate namespace from enrollment intents. */
package/browser/index.mjs CHANGED
@@ -10,7 +10,7 @@ export async function openEnrollmentSession({store,...loginOptions}) {
10
10
  try {
11
11
  const helpers=createPlaywrightEnrollmentHelpers({page:session.page,store,...session.connection});
12
12
  const reader=createPlaywrightGwtSession({page:session.page,...session.connection});
13
- const enrollmentSession={connection:session.connection,readEnrollment:target=>withPageLock(session.page,()=>reader.readEnrollment(target))};
13
+ const enrollmentSession={connection:session.connection,readEnrollment:target=>withPageLock(session.page,()=>reader.readEnrollment(target)),listEnrollments:target=>withPageLock(session.page,()=>reader.listEnrollments(target))};
14
14
  return Object.assign(session,{helpers,enrollmentSession,workflows:createClientWorkflows({page:session.page,store})});
15
15
  }catch(error){await session.close();throw error;}
16
16
  }
@@ -21,7 +21,7 @@ export {createServiceUi} from './service-ui.mjs';
21
21
  export {createServiceRecorder} from './service-recording.mjs';
22
22
  export {getSsvfServiceRules} from './ssvf-rules.mjs';
23
23
  export {createProgramDiscovery} from './program-discovery.mjs';
24
- export {createEnrollmentWorkflow,EnrollmentWorkflowError} from './enrollment-workflow.mjs';
24
+ export {createEnrollmentWorkflow,EnrollmentWorkflowError,EnrollmentReadError} from './enrollment-workflow.mjs';
25
25
  export {createMemberEntryDatePort} from './member-entry-date.mjs';
26
26
  export {createHouseholdExitUi} from './household-exit.mjs';
27
27
 
@@ -47,22 +47,25 @@ export function createPlaywrightGwtSession({page,tenant,moduleBaseUrl}) {
47
47
  }
48
48
  throw Error('Could not close enrollment dialogs');
49
49
  };
50
- const openTarget=async(target)=>{
51
- id(target.clientId);id(target.enrollmentId);await guard();await closeDialogs();
52
- const clientHeading=page.getByText(new RegExp(`^Client - \\(${target.clientId}\\)`));
50
+ const openClient=async(clientId)=>{
51
+ id(clientId);await guard();await closeDialogs();
52
+ const clientHeading=page.getByText(new RegExp(`^Client - \\(${clientId}\\)`));
53
53
  if(!await clientHeading.isVisible()){
54
- const switcher=page.locator('select:visible').filter({has:page.locator(`option[value="${target.clientId}"]`)});
54
+ const switcher=page.locator('select:visible').filter({has:page.locator(`option[value="${clientId}"]`)});
55
55
  if(await switcher.count()===1&&await control(page,'ClientMainView.householdSubmitButton').isVisible()){
56
- await switcher.selectOption(String(target.clientId));await control(page,'ClientMainView.householdSubmitButton').click();
56
+ await switcher.selectOption(String(clientId));await control(page,'ClientMainView.householdSubmitButton').click();
57
57
  }else{
58
58
  // A hash alone is not proof that GWT switched the displayed client.
59
- if(new URL(page.url()).hash===`#loadClient;clientId=${target.clientId}`)throw Error('Client navigation did not load the requested client');
60
- await page.goto(moduleBaseUrl+`index.html#loadClient;clientId=${target.clientId}`);
59
+ if(new URL(page.url()).hash===`#loadClient;clientId=${clientId}`)throw Error('Client navigation did not load the requested client');
60
+ await page.goto(moduleBaseUrl+`index.html#loadClient;clientId=${clientId}`);
61
61
  }
62
62
  await clientHeading.waitFor();await ready();
63
63
  }
64
64
  await page.getByRole('tab',{name:'Summary',exact:true}).click();await ready();
65
65
  await page.getByRole('tab',{name:'Entry / Exit',exact:true}).click();await ready();
66
+ };
67
+ const openTarget=async(target)=>{
68
+ id(target.enrollmentId);await openClient(target.clientId);
66
69
  const edit=control(page,`ClientEntryExitTab.editEntry-imageLink_${target.enrollmentId}`);
67
70
  await edit.waitFor();
68
71
  const response=page.waitForResponse(r=>r.url()===endpoint&&r.request().postData()?.includes('|getEntryExit|')&&r.request().postData()?.endsWith(`|${target.enrollmentId}|`));
@@ -97,6 +100,25 @@ export function createPlaywrightGwtSession({page,tenant,moduleBaseUrl}) {
97
100
  if(ids.some(n=>!Number.isSafeInteger(n)||n<=0))throw Error('Invalid household tree');
98
101
  await closeDialogs();return [...new Set([target.clientId,...ids])].sort((a,b)=>a-b);
99
102
  };
103
+ const listEnrollments=async({clientId})=>{
104
+ id(clientId);await guard();wire=undefined;let captured;
105
+ const listener=request=>{
106
+ if(request.url()===moduleBaseUrl+'service/MetaQueryService'&&request.postData()?.startsWith('7|0|')){
107
+ const policy=request.postData().split('|')[4];
108
+ captured=request.allHeaders().then(headers=>({policy,headers:Object.fromEntries(['content-type','x-gwt-module-base','x-gwt-permutation'].map(k=>[k,headers[k]]))}));
109
+ }
110
+ };
111
+ page.on('request',listener);
112
+ try{await openClient(clientId);wire=await captured;}finally{page.off('request',listener);}
113
+ if(!wire?.policy||Object.values(wire.headers).some(v=>!v))throw Error('Fresh enrollment list context unavailable');
114
+ 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 FROM SpEntryExit AS e WHERE e.active = TRUE AND e.client.active = TRUE AND e.clientId = ${clientId} ORDER BY e.entryExitId`);
115
+ const ids=new Set();
116
+ for(const row of rows){
117
+ 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');
118
+ ids.add(row.enrollmentId);
119
+ }
120
+ return {source:'gwt-rpc',tenant,clientId,count:rows.length,values:rows.map(row=>({...row,isActive:true}))};
121
+ };
100
122
  const readEnrollment=async target=>{
101
123
  const model=await openTarget(target),householdClientIds=await householdIds(target);
102
124
  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)}`);
@@ -190,7 +212,7 @@ export function createPlaywrightGwtSession({page,tenant,moduleBaseUrl}) {
190
212
  try{await save.click();const result=await Promise.race([captured,new Promise(resolve=>{timer=setTimeout(()=>resolve(null),15000);})]);if(!result)throw Error('Expected mutation was not captured');pending=digest(JSON.stringify({url:result.url,headers:result.headers,body:result.body}));return result;}
191
213
  finally{clearTimeout(timer);await page.unroute('**/service/**',handler);await ready();await closeDialogs();}
192
214
  };
193
- return {connection:resolveUiConnection({tenant,moduleBaseUrl}),readEnrollment,prepare,async send(request){
215
+ return {connection:resolveUiConnection({tenant,moduleBaseUrl}),readEnrollment,listEnrollments,prepare,async send(request){
194
216
  checkGeneration();const fingerprint=digest(JSON.stringify(request));if(!pending||fingerprint!==pending)throw Error('Only the newly prepared request can be sent once');pending=undefined;return post(request);
195
217
  }};
196
218
  }
@@ -21,6 +21,7 @@ The generated client covers the pinned V2/V3 Swagger operations. The following f
21
21
  | Later child or member | `addHouseholdMembers` | Must already belong to household; own later entry date, assessment, and DOB; existing dates preserved |
22
22
  | Interims | Workflow `createInterim`, `createHouseholdInterim` or low-level GWT `createInterimUpdate` | Workflow populates answers; low-level helper creates only review/participant links. Selected household members supported |
23
23
  | Household exit | `exitHousehold` with exit UI port | Selected members, staggered dates, subsidy/Other details, per-member exit assessments; all-member metadata uses one GWT save, assessments follow separately |
24
+ | Individual enrollment exit | `exitEnrollment` with enrollment reader and exit UI port | Standalone participant, verified entry journal, exit details and assessment, same-key reconciliation; integrator-reported sandbox acceptance November 10, 2026 |
24
25
  | Head of household | Enrollment workflow guards | Missing or mid-enrollment replacement head rejected. Head cannot exit before remaining members. Retroactive replacement is not implemented |
25
26
  | Verification records at entry | `readEnrollmentVerifications` | Reads generic assessment and filters intervals; complete-enrollment entry arrays can omit valid UI rows |
26
27
  | Existing verification end dates | `api.subassessments.setEndDate` | Preserves row ID and other values, verifies result; no blind resend or delete/recreate |
@@ -43,7 +44,7 @@ The generated client covers the pinned V2/V3 Swagger operations. The following f
43
44
 
44
45
  ## Known server gaps
45
46
 
46
- Grouped REST enrollment reads can return `42703: column v.entry_date_effective does not exist` after both UI and RPC saves. This is not specific to replay. A broken read is not an absent enrollment. The browser enrollment reader uses current UI/GWT evidence. The enrollment workflow and, given `enrollmentSession`, the Current Living Situation and Coordinated Entry Event recorders read household enrollments through it.
47
+ Grouped REST enrollment reads can return `42703: column v.entry_date_effective does not exist` after both UI and RPC saves. This is not specific to replay. A broken read is not an absent enrollment. The browser enrollment reader uses current UI/GWT evidence. The configured enrollment workflow now uses its counted GWT catalog for creation preflight as well as targeted GWT reads. The Current Living Situation and Coordinated Entry Event recorders use targeted GWT reads when given `enrollmentSession`. Legacy custom readers without `listEnrollments` retain REST list behavior.
47
48
 
48
49
  The October 24 [V2/V3 comparison](vendor-issues/enrollment-effective-date-column.md) reproduced the missing-column error for all 16 tested grouped complete enrollments in each version. Individual-only client lists succeeded, and individual pages within a mixed client also succeeded. Switching to V2 does not fix the issue. One individual complete read had a separate `Non-static method requires a target.` error.
49
50
 
@@ -75,3 +76,13 @@ Independent needs and AIRS/service-type discovery use UI/GWT helpers. See [indep
75
76
  The [separate complete-enrollment target error](vendor-issues/enrollment-complete-target-error.md) was isolated to missing DOB information in controlled sandbox fixtures. Adding only `SVPPROFDOB`, or only `SVPPROFDOBTYPE` with `DOBTYPE_DONTKNOW` or `DOBTYPE_REFUSED`, changed HTTP 400 to success in V2 and V3. The quality-only fixtures retained empty DOB values, confirmed through REST and the UI. Never invent a DOB or recreate an enrollment to recover a read.
76
77
 
77
78
  Current Living Situation contact recording is available through `createCurrentLivingSituationRecorder`. It preserves distinct contacts with program-entry starts and optional recording-program verification. See the [contact guide](current-living-situation.md) for metadata mapping, date contracts, blocked server reads, and acceptance limits.
79
+
80
+ ## Integrator-reported acceptance, November 10, 2026
81
+
82
+ The integrator reported these results using SDK 0.4.1 in the training tenant. They are reported live evidence, not a fresh SDK-team rerun. Client and enrollment identifiers are omitted from this public package.
83
+
84
+ - A standalone enrollment exited on November 9 and the person was re-enrolled. Repeating the same keys produced no additional writes.
85
+ - Two household cohorts exited successfully using plans from `discoverExit`, carrying forward fields with values and marking the rest not applicable.
86
+ - After those household exits, both re-enrollment attempts failed on the pre-create REST list with HTTP 400 from the grouped-enrollment `42703` gap. This confirms that an exited household enrollment can still break that REST list.
87
+
88
+ The GWT catalog correction has regression coverage for individual creation, household heads and member preflight, failed-read classification and re-enrollment. Same-date entry corrections and adoption of manual enrollments have synthetic coverage. These newer paths have not been independently verified live.
@@ -53,7 +53,7 @@ See [CHANGELOG.md](../CHANGELOG.md) for compatibility changes. This package is a
53
53
 
54
54
  GitLab is the source of truth. Its push mirror copies branches and tags to `nmbgeek/communityservices-sdk` on GitHub. Commit changes, including `.github/workflows/publish.yml`, in GitLab. Do not edit the mirror or push independent commits there.
55
55
 
56
- The GitHub workflow checks Node 22, Node 24, and Chromium using synthetic fixtures. A matching `v<version>` tag publishes the tested Chromium-job tarball as public `@choiceapps/communityservices-sdk`. Main pushes only verify. To recover an interrupted release, run `publish.yml` manually against the existing version tag. An identical existing artifact is verified without republishing; a different artifact under the same version fails. npm versions cannot be replaced.
56
+ The GitHub workflow uses Ubuntu 24.04 runners and checks Node 22, Node 24, and Chromium using synthetic fixtures. The runner version is explicit because the pinned Playwright version cannot install Chromium on Ubuntu 26.04. A matching `v<version>` tag publishes the tested Chromium-job tarball as public `@choiceapps/communityservices-sdk`. Main pushes only verify. To recover an interrupted release, run `publish.yml` manually against the existing version tag. An identical existing artifact is verified without republishing; a different artifact under the same version fails. npm versions cannot be replaced.
57
57
 
58
58
  Configure the npm package's trusted publisher for GitHub owner `nmbgeek`, repository `communityservices-sdk`, workflow filename `publish.yml`, with publishing allowed and no environment name. The workflow runs on GitHub-hosted runners, grants `id-token: write` only to the publish job, and uses npm 11.19.1. It needs no `NPM_TOKEN` or `NODE_AUTH_TOKEN` secret. The package's repository URL points to this public mirror so npm can attach provenance. GitLab publication remains a separate job using `CI_JOB_TOKEN`.
59
59
 
@@ -128,8 +128,40 @@ In this mode the mobile batch carries no yes/no answers. After it verifies, a se
128
128
 
129
129
  Every mandatory label is resolved before the first write. The `yesno` stage verifies with a generic read one second after the effective instant, because a read at the instant omits yes/no answers, and requires each answer and its effective timestamp to match. The mode is recorded in the journal. An unfinished workflow started in one mode fails in the other rather than switching endpoints, including journals left pending by earlier mobile yes/no attempts; those require separate reconciliation. Yes/no children of new subassessment rows still use the mobile row write, which drops them, so leave them unanswered in workflow rows. Record Coordinated Entry Events, which need yes/no and provider answers, with the [event recorder](coordinated-entry-events.md).
130
130
 
131
+ ## Correct an existing entry assessment
132
+
133
+ `correctEntry` patches an entry at its original effective timestamp. Supply the verified `createEnrollment` key as `enrollmentKey` and a new stable `key` for each correction. Configure `enrollmentSession` and a `writeLabel` resolver for tenant-verified lookup labels. A correction requires an open enrollment with no interim reviews or exit workflows.
134
+
135
+ ```ts
136
+ const corrected = await workflow.correctEntry({
137
+ key: sourceCorrectionId,
138
+ enrollmentKey: sourceEnrollmentId,
139
+ clientId,
140
+ answers: changedScalarAnswers,
141
+ clear: scalarFieldsNoLongerAsked,
142
+ subassessments: [{
143
+ key: originalEntryRowKey,
144
+ parentField,
145
+ answers: changedRowAnswers,
146
+ clear: rowFieldsToClear,
147
+ }],
148
+ });
149
+ ```
150
+
151
+ `answers` uses the ordinary answer and option selectors. `clear` explicitly removes a recorded value; an omitted field stays unchanged. Clearing a yes/no answer is distinct from answering `No`. Do not use `notApplicable` to clear an old answer. Every row key must identify an existing row returned by the original entry operation. The correction never creates a second row, deletes a row, or changes the enrollment date.
152
+
153
+ Scalars use the standard assessment endpoint, which supports same-date updates. The batch includes every mandatory scalar, preserving its current value unless the patch explicitly answers or clears it. Missing mandatory values require an explicit answer or clear; lookup values require verified write labels. Readback checks one second after the original instant so stored yes/no values are visible.
154
+
155
+ Row edits send only the changed cells to the existing recordset through the mobile endpoint. Lookup, text, numeric and date cells are supported; provider and yes/no row cells are refused before any write. Untouched cells, all other rows and row IDs must remain unchanged. DOB and household-head changes require their dedicated workflows and cannot be supplied as corrections here. An unmodified mandatory DOB is also refused because the standard batch cannot safely resend it through the DOB helper.
156
+
157
+ All changes are validated before the first write. Scalar and row batches have separate journal stages, so a partial correction is not rolled back. Retry the same intent and key to reconcile; an uncertain batch is never resent automatically. `getStatus({clientId, key})` reports correction progress. Verified corrections update the original entry's verification expectations, so retrying `createEnrollment` under its old key does not restore the old path. A later correction uses a new correction key. See the [typed example](../examples/entry-correction-workflow.mts).
158
+
131
159
  ## Household enrollment and interim assessments
132
160
 
161
+ Enrollment creation and member preflight use the complete GWT list when the configured `enrollmentSession` supplies `listEnrollments`. The built-in sessions supply it. This avoids the REST `42703` failure for clients with existing household enrollments, including exited enrollments and enrollments in other programs. Reads verify the client, tenant, row identities and count; a failed GWT read never falls back to REST or means an empty list. The current GWT reader refuses lists above 1,000 records. Legacy custom readers without this method retain the counted REST path.
162
+
163
+ An unreadable list throws `EnrollmentReadError` with `code: 'ENROLLMENT_READ_FAILED'`, `source`, `clientId`, and the original error as `cause`. `phase: 'preflight'` and `committed: false` mean this workflow has not attempted a server write. After a write attempt, the error reports `phase: 'reconciliation'` and `committed: 'unknown'`. Retain the existing key and journal in both cases. A lost write followed by a failed read is never a non-commit.
164
+
133
165
  The same workflow exposes `createHouseholdEnrollment` and `createHouseholdInterim`. Supply `enrollmentSession: session.enrollmentSession` when constructing it. Initial household enrollment requires an existing household and at least two existing clients, including its head. Later interims and exits accept a nonempty subset of enrolled members. Establish household membership separately before enrollment.
134
166
 
135
167
  ```ts
@@ -164,6 +196,38 @@ Client locks coordinate overlapping household and single-client operations. Dura
164
196
 
165
197
  See the typed [household example](../examples/household-enrollment-workflow.mts) and [live household verification](capabilities.md). The integration uses the configured tenant and uses an isolated authenticated page.
166
198
 
199
+ ## Individual enrollment exits
200
+
201
+ Use `exitEnrollment` to end a standalone person's program enrollment. It requires a verified `createEnrollment` entry in the same durable store. Supply `enrollmentSession: session.enrollmentSession` and `exitUi: createHouseholdExitUi({page: session.page})` when constructing the workflow. The same exit UI port serves individual and household forms.
202
+
203
+ Call `discoverExit` with the person's client, provider, type and entry date, then build their assessment plan from `catalog.exit.definition`.
204
+
205
+ ```ts
206
+ const exit = await workflow.exitEnrollment({
207
+ key: sourceExitId,
208
+ enrollmentKey: sourceEnrollmentId,
209
+ clientId,
210
+ exitDate,
211
+ destination: 'Rental by client, no ongoing housing subsidy',
212
+ reasonForLeaving: 'Completed program',
213
+ assessment: exitPlan,
214
+ });
215
+ ```
216
+
217
+ The workflow verifies the exact enrollment and sole participant through fresh GWT reads, checks exit dates against entry and interim dates, and validates the assessment before saving. It journals one exit save attempt and verifies the date, destination, reason and conditional details before writing the assessment. Individual exits can have a null `exitSubgroupId`; the result preserves that value. The journal pins any returned subgroup identity on retries.
218
+
219
+ Retain the same key, intent and store after failures. A lost save response triggers readback, never an automatic resend. Use `getStatus({clientId, key})` to inspect local progress. Exit metadata and assessment writes are separate stages, so a saved exit can remain pending assessment verification. `endDates` closes only the explicitly selected verification rows; enrollment exit does not automatically end every verification record.
220
+
221
+ For a person in a linked household enrollment, use `exitHousehold` with that person as the only selected member. `exitEnrollment` rejects linked groups to preserve household head coverage. Neither method ends household membership. See the [typed individual exit example](../examples/individual-exit-workflow.mts). The integrator reported successful standalone exit and same-key no-write retry acceptance on November 10, 2026; see [reported sandbox acceptance](capabilities.md#integrator-reported-acceptance-november-10-2026).
222
+
223
+ ## Adopt an enrollment entered manually in WellSky
224
+
225
+ 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.
226
+
227
+ 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.
228
+
229
+ After adoption, pass its key as `enrollmentKey` to `exitEnrollment` or `exitHousehold`. Exit assessment plans and normal household head rules still apply. Adoption records existing enrollment identities, not historical subassessment row-key mappings, so its returned `recordsetIds` is empty. It cannot identify rows for `correctEntry` by a new invented key. See [manual-entry exit examples](../examples/existing-enrollment-exits.mts).
230
+
167
231
  ## Household exits
168
232
 
169
233
  Add `exitUi: createHouseholdExitUi({page: session.page})` to the workflow options. `discoverExit` returns the provider's entry, interim and exit definitions; use the exit definition to build each person's plan.
@@ -183,6 +247,8 @@ const exit = await workflow.exitHousehold({
183
247
 
184
248
  This requires a verified household entry in the same durable workflow store. Supply only the members exiting on this date, including a single member if needed; the household head need not be selected. Use separate stable keys for exits on different dates. Every exit plan is validated before saving. The browser submits one shared exit, then authoritative GWT reads verify every member's exit date, destination, reason and exact linked exit group before individual assessment writes. The returned members include `projectExitDate`, `exitSubgroupId` and any new verification `recordsetIds`. Exit dates cannot precede entry or an existing interim.
185
249
 
250
+ To end the entire household's program enrollment, include every enrolled member who has not already exited. Already exited members retain their earlier dates. The existing head-coverage checks still apply.
251
+
186
252
  Use `endDates` only for verification records that changed, with replacement rows in `subassessments`; use `carryForward` for unchanged groups. Ending enrollment does not automatically end all verification records. Stored dates are never shifted. Discovery and completeness checks do not decide individual eligibility or which verification types apply.
187
253
 
188
254
  Reusing the same key and unchanged plans reconciles against fresh reads without repeating writes, including after a lost save response. A partial household exit, unexpected linked member, conflicting exit metadata, schema drift, or an uncertain uncommitted save fails closed. Do not change keys or delete journals to bypass these checks. This remains a non-atomic sequence and does not prevent unrelated integrations or users from editing concurrently.
package/docs/reference.md CHANGED
@@ -25,7 +25,7 @@ Runtime exports at the root: `AmbiguousCommitError`, `AssessmentMetadata`, `Auth
25
25
 
26
26
  Import from `@choiceapps/communityservices-sdk/browser` in server-side ESM. These helpers use the tenant supplied by your application. Browser interfaces require `playwright-core`; some orchestration helpers use REST plus injected UI ports rather than directly launching a browser.
27
27
 
28
- Runtime exports: `AttachmentConflictError`, `CoordinatedEntryEventConflictError`, `EnrollmentWorkflowError`, `LoginError`, `SessionInterruptedError`, `createFileGwtMutationStore`, `createPlaywrightEnrollmentHelpers`, `createPlaywrightGwtSession`, `createCasePlanUi`, `createCasePlans`, `createClientCreator`, `createClientProfile`, `createClientProfileUi`, `createClientWorkflows`, `createCoordinatedEntryEventRecorder`, `createDateOfBirth`, `createDateOfBirthUi`, `createEnrollmentWorkflow`, `createHouseholdExitUi`, `createHouseholdUi`, `createHouseholds`, `createMemberEntryDatePort`, `createProgramDiscovery`, `createServiceRecorder`, `createServiceUi`, `getHouseholdOptions`, `getSsvfServiceRules`, `openEnrollmentSession`, `openUiSession`, `validateBirthDate`, `resolveUiConnection`.
28
+ Runtime exports: `AttachmentConflictError`, `CoordinatedEntryEventConflictError`, `EnrollmentWorkflowError`, `EnrollmentReadError`, `LoginError`, `SessionInterruptedError`, `createFileGwtMutationStore`, `createPlaywrightEnrollmentHelpers`, `createPlaywrightGwtSession`, `createCasePlanUi`, `createCasePlans`, `createClientCreator`, `createClientProfile`, `createClientProfileUi`, `createClientWorkflows`, `createCoordinatedEntryEventRecorder`, `createDateOfBirth`, `createDateOfBirthUi`, `createEnrollmentWorkflow`, `createHouseholdExitUi`, `createHouseholdUi`, `createHouseholds`, `createMemberEntryDatePort`, `createProgramDiscovery`, `createServiceRecorder`, `createServiceUi`, `getHouseholdOptions`, `getSsvfServiceRules`, `openEnrollmentSession`, `openUiSession`, `validateBirthDate`, `resolveUiConnection`.
29
29
 
30
30
  | Task | Contract | Methods or behavior |
31
31
  | --- | --- | --- |
@@ -36,7 +36,7 @@ Runtime exports: `AttachmentConflictError`, `CoordinatedEntryEventConflictError`
36
36
  | Profile fields and Sex | [Client profile](../browser/client-profile.d.mts) | `read`, `update` |
37
37
  | Calendar DOB | [Birth dates](../browser/date-of-birth.d.mts) | `read`, `validate`, `verify`, `update` |
38
38
  | Provider assessment defaults | [Discovery](../browser/program-discovery.d.mts) | `listAssessments`, `getProviderAssessments` |
39
- | Enrollment and household lifecycle | [Workflow](../browser/enrollment-workflow.d.mts) | Discovery, entry, member additions, interim, exit, journal status; `EntryExitType` and per-type `assessmentLocations` in [enrollments](enrollments.md) |
39
+ | Enrollment and household lifecycle | [Workflow](../browser/enrollment-workflow.d.mts) | Discovery, entry, `correctEntry`, `adoptEnrollment`, `adoptHouseholdEnrollment`, member additions, interim, `exitEnrollment`, `exitHousehold`, journal status; `EntryExitType` and per-type `assessmentLocations` in [enrollments](enrollments.md) |
40
40
  | 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 |
41
41
  | Services and recovery diagnostics | [Service recording](../browser/service-recording.d.mts) | `record`, `recordBulk`, Funding Sources, `RecoveryDiagnostic` |
42
42
  | SSVF conditional rules | [SSVF types](../browser/ssvf-rules.d.mts) | [Field rules](../SSVF.md) and dated option snapshots |
@@ -14,6 +14,9 @@ Every file here ships with the package and imports the public `@choiceapps/commu
14
14
  | [verification-snapshot.ts](verification-snapshot.ts) | Read assessment records effective at enrollment | Exported function; caller supplies assessment context |
15
15
  | [program-discovery.ts](program-discovery.ts) | Program and VA-entry assessment discovery | Exported function; authenticated page required |
16
16
  | [enrollment-workflow.mts](enrollment-workflow.mts) | Single entry and interim assessment plans | Exported function; caller supplies plans and dependencies |
17
+ | [individual-exit-workflow.mts](individual-exit-workflow.mts) | Standalone program enrollment exit and assessment | Verified entry key, enrollment reader, exit UI port and durable store required |
18
+ | [entry-correction-workflow.mts](entry-correction-workflow.mts) | Same-date scalar clears and existing row edits | Original entry/row keys, verified lookup labels and durable store required |
19
+ | [existing-enrollment-exits.mts](existing-enrollment-exits.mts) | Adopt manual individual or household entries, then exit | Explicit enrollment identities and original household head required |
17
20
  | [household-enrollment-workflow.mts](household-enrollment-workflow.mts) | Per-member enrollment and review | Existing household and per-member answer plans required |
18
21
  | [household-lifecycle-workflow.mts](household-lifecycle-workflow.mts) | Selected exits and a later child entry | Household membership precedes program attachment |
19
22
  | [household-exit-workflow.mts](household-exit-workflow.mts) | Household exit with destination details | Explicit exit dates and per-member plans required |
@@ -0,0 +1,14 @@
1
+ import {
2
+ createEnrollmentWorkflow,
3
+ type EnrollmentWorkflowOptions,
4
+ type EntryCorrectionInput,
5
+ } from '@choiceapps/communityservices-sdk/browser';
6
+
7
+ /** The application supplies explicit answers, clears and original row keys.
8
+ * Dependencies include enrollmentSession and a verified writeLabel resolver.
9
+ * Reuse the original entry store; input.key identifies this correction, while
10
+ * input.enrollmentKey identifies the existing createEnrollment operation. */
11
+ export async function correctOriginalEntry(dependencies:EnrollmentWorkflowOptions,input:EntryCorrectionInput){
12
+ const workflow=createEnrollmentWorkflow(dependencies);
13
+ return workflow.correctEntry(input);
14
+ }
@@ -0,0 +1,30 @@
1
+ import {
2
+ createEnrollmentWorkflow,
3
+ type EnrollmentWorkflowOptions,
4
+ type AdoptEnrollmentInput,
5
+ type AdoptHouseholdEnrollmentInput,
6
+ type ExitEnrollmentInput,
7
+ type HouseholdExitInput,
8
+ } from '@choiceapps/communityservices-sdk/browser';
9
+
10
+ /** Adoption reads WellSky and records its identities locally; it creates no enrollment. */
11
+ export async function exitManualEnrollment(
12
+ dependencies:EnrollmentWorkflowOptions,
13
+ existing:AdoptEnrollmentInput,
14
+ exit:Omit<ExitEnrollmentInput,'enrollmentKey'|'clientId'>,
15
+ ){
16
+ const workflow=createEnrollmentWorkflow(dependencies);
17
+ await workflow.adoptEnrollment(existing);
18
+ return workflow.exitEnrollment({...exit,enrollmentKey:existing.key,clientId:existing.clientId});
19
+ }
20
+
21
+ /** Adopt the complete linked cohort, then exit only the members selected in exit.members. */
22
+ export async function exitManualHousehold(
23
+ dependencies:EnrollmentWorkflowOptions,
24
+ existing:AdoptHouseholdEnrollmentInput,
25
+ exit:Omit<HouseholdExitInput,'enrollmentKey'|'headClientId'>,
26
+ ){
27
+ const workflow=createEnrollmentWorkflow(dependencies);
28
+ await workflow.adoptHouseholdEnrollment(existing);
29
+ return workflow.exitHousehold({...exit,enrollmentKey:existing.key,headClientId:existing.headClientId});
30
+ }
@@ -0,0 +1,16 @@
1
+ import {
2
+ createEnrollmentWorkflow,
3
+ type EnrollmentWorkflowOptions,
4
+ type ExitEnrollmentInput,
5
+ type ExitEnrollmentResult,
6
+ } from '@choiceapps/communityservices-sdk/browser';
7
+
8
+ /** Supply enrollmentSession and exitUi, and the durable store containing the
9
+ * verified createEnrollment operation identified by input.enrollmentKey.
10
+ * Build input.assessment from workflow.discoverExit before calling this function. */
11
+ export async function exitIndividualEnrollment(
12
+ dependencies:EnrollmentWorkflowOptions,
13
+ input:ExitEnrollmentInput,
14
+ ):Promise<ExitEnrollmentResult> {
15
+ return createEnrollmentWorkflow(dependencies).exitEnrollment(input);
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@choiceapps/communityservices-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
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",