@choiceapps/communityservices-sdk 0.6.0 → 0.7.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/browser/enrollment-workflow.d.mts +25 -3
  3. package/browser/enrollment-workflow.mjs +75 -29
  4. package/browser/entry-correction.mjs +6 -4
  5. package/browser/entry-row-registration.mjs +63 -0
  6. package/browser/household-enrollment-discovery.mjs +49 -0
  7. package/browser/index.d.mts +3 -1
  8. package/browser/playwright-session.mjs +4 -3
  9. package/browser/workflow-error-codes.mjs +198 -0
  10. package/dist/cjs/mobile-preflight.d.ts +19 -0
  11. package/dist/cjs/mobile-preflight.d.ts.map +1 -0
  12. package/dist/cjs/mobile-preflight.js +75 -0
  13. package/dist/cjs/mobile-preflight.js.map +1 -0
  14. package/dist/cjs/v2.d.ts +2 -0
  15. package/dist/cjs/v2.d.ts.map +1 -1
  16. package/dist/cjs/v2.js +2 -0
  17. package/dist/cjs/v2.js.map +1 -1
  18. package/dist/cjs/v3.d.ts +2 -0
  19. package/dist/cjs/v3.d.ts.map +1 -1
  20. package/dist/cjs/v3.js +2 -0
  21. package/dist/cjs/v3.js.map +1 -1
  22. package/dist/cjs/write-labels.d.ts +13 -0
  23. package/dist/cjs/write-labels.d.ts.map +1 -0
  24. package/dist/cjs/write-labels.js +68 -0
  25. package/dist/cjs/write-labels.js.map +1 -0
  26. package/dist/esm/mobile-preflight.d.ts +19 -0
  27. package/dist/esm/mobile-preflight.d.ts.map +1 -0
  28. package/dist/esm/mobile-preflight.js +70 -0
  29. package/dist/esm/mobile-preflight.js.map +1 -0
  30. package/dist/esm/v2.d.ts +2 -0
  31. package/dist/esm/v2.d.ts.map +1 -1
  32. package/dist/esm/v2.js +2 -0
  33. package/dist/esm/v2.js.map +1 -1
  34. package/dist/esm/v3.d.ts +2 -0
  35. package/dist/esm/v3.d.ts.map +1 -1
  36. package/dist/esm/v3.js +2 -0
  37. package/dist/esm/v3.js.map +1 -1
  38. package/dist/esm/write-labels.d.ts +13 -0
  39. package/dist/esm/write-labels.d.ts.map +1 -0
  40. package/dist/esm/write-labels.js +65 -0
  41. package/dist/esm/write-labels.js.map +1 -0
  42. package/docs/assessments.md +31 -0
  43. package/docs/capabilities.md +13 -0
  44. package/docs/enrollments.md +38 -4
  45. package/docs/reference.md +3 -2
  46. package/docs/troubleshooting.md +17 -0
  47. package/examples/README.md +2 -0
  48. package/examples/checked-assessment-writes.mts +14 -0
  49. package/examples/enrollment-discovery-and-correction.mts +19 -0
  50. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 - 2026-11-10
4
+
5
+ - Add `createReadBackWriteLabelResolver` for exact observed lookup labels, including HUD suffixes. Unobserved options remain unresolved; no suffix is inferred from metadata.
6
+ - Expose `groupId` and `grouped` on GWT enrollment catalog rows and details without per-row reads.
7
+ - Add `discoverHouseholdEnrollment` and adoption from a head's client/enrollment IDs, requiring a unique verified household membership match.
8
+ - Add `registerEntryRows` to bind existing recordsets to adopted entry keys for in-place corrections.
9
+ - Add stable workflow error codes and per-operation `committed` markers, preserving uncertain prior writes and original causes.
10
+ - Add `writeMobileAnswersChecked` and enrollment-workflow scalar collision preflight for questions shared across instruments.
11
+ - Document missing diagnostics in 0.5.0 journals, the unsupported yes/no clear route, and integrator-reported 0.6.0 acceptance separately from local tests.
12
+
3
13
  ## 0.6.0 - 2026-11-10
4
14
 
5
15
  - Reject yes/no fields in `correctEntry.clear` before any correction write or new journal entry. Correct synthetic fixtures that incorrectly accepted blank yes/no values. Setting `No` remains supported.
@@ -107,6 +107,19 @@ export interface AdoptHouseholdEnrollmentInput {
107
107
  /** Complete linked cohort, including already exited participants. IDs are explicit, not inferred. */
108
108
  members:Array<{clientId:number;enrollmentId:number}>;
109
109
  }
110
+ export interface HouseholdEnrollmentDiscoveryInput {headClientId:number;headEnrollmentId:number;}
111
+ export interface HouseholdEnrollmentDiscoveryResult extends HouseholdEnrollmentDiscoveryInput {
112
+ householdId:number;householdSource:'verified-membership';providerId:number;type:string;
113
+ members:Array<{clientId:number;enrollmentId:number}>;
114
+ }
115
+ export interface AdoptHouseholdEnrollmentFromHeadInput extends HouseholdEnrollmentDiscoveryInput {key:string;}
116
+ export interface RegisterEntryRowsInput {
117
+ key:string;enrollmentKey:string;clientId:number;
118
+ rows:Array<{key:string;parentField:string;recordsetId:number}>;
119
+ }
120
+ export interface RegisterEntryRowsResult {
121
+ key:string;enrollmentKey:string;clientId:number;enrollmentId:number;outcome:'verified'|'already-present';recordsetIds:Record<string,number>;
122
+ }
110
123
  export interface EnrollmentWorkflowResult {
111
124
  key:string;
112
125
  outcome:'verified'|'already-present';
@@ -146,12 +159,16 @@ export interface EnrollmentWorkflow {
146
159
  exitEnrollment(input:ExitEnrollmentInput):Promise<ExitEnrollmentResult>;
147
160
  /** Patch an open entry at its original timestamp, before any review or exit. */
148
161
  correctEntry(input:EntryCorrectionInput):Promise<EntryCorrectionResult>;
162
+ /** Bind explicit existing row IDs to stable keys without writing HMIS records. */
163
+ registerEntryRows(input:RegisterEntryRowsInput):Promise<RegisterEntryRowsResult>;
164
+ /** Resolve a unique current household satisfying full-cohort membership/head/date checks. */
165
+ discoverHouseholdEnrollment(input:HouseholdEnrollmentDiscoveryInput):Promise<HouseholdEnrollmentDiscoveryResult>;
149
166
  /** Verify unchanged state and independently confirmed rejection; keeps the attempt history. */
150
167
  resolveEntryCorrection(input:ResolveEntryCorrectionInput):Promise<ResolveEntryCorrectionResult>;
151
168
  /** Verify an existing standalone entry and journal it without an HMIS write. */
152
169
  adoptEnrollment(input:AdoptEnrollmentInput):Promise<EnrollmentWorkflowResult>;
153
170
  /** Verify all existing linked entries, original head and dates; no HMIS writes. */
154
- adoptHouseholdEnrollment(input:AdoptHouseholdEnrollmentInput):Promise<HouseholdEnrollmentResult>;
171
+ adoptHouseholdEnrollment(input:AdoptHouseholdEnrollmentInput|AdoptHouseholdEnrollmentFromHeadInput):Promise<HouseholdEnrollmentResult>;
155
172
  /** Reads the local journal only. A verified journal is not fresh server verification. */
156
173
  getStatus(input:{clientId:number;key:string}):Promise<EnrollmentWorkflowStatus|null>;
157
174
  createHouseholdEnrollment(input:HouseholdEnrollmentInput):Promise<HouseholdEnrollmentResult>;
@@ -242,12 +259,17 @@ export interface EnrollmentWorkflowOptions {
242
259
  }
243
260
  export class EnrollmentWorkflowError extends Error {
244
261
  readonly reason:string;
245
- readonly code?:string;
262
+ readonly code:string;
263
+ readonly committed:false|'unknown';
264
+ readonly operation?:string;
265
+ /** Resolution-call failure does not establish the original correction's outcome. */
266
+ readonly originalCommitted?:'unknown';
267
+ readonly reauthenticated?:boolean;
246
268
  readonly fields?:string[];
247
269
  readonly status?:number;
248
270
  /** Failed durable stage, when known. The original write error is available as cause in memory. */
249
271
  readonly step?:string;
250
- constructor(reason:string,options?:ErrorOptions&{step?:string;code?:string;fields?:string[];status?:number});
272
+ constructor(reason:string,options?:ErrorOptions&{step?:string;code?:string;fields?:string[];status?:number;committed?:false|'unknown'});
251
273
  }
252
274
  /** An unavailable list is never evidence that no enrollment exists. */
253
275
  export class EnrollmentReadError extends EnrollmentWorkflowError {
@@ -2,6 +2,9 @@ import {apiScope} from './connection.mjs';
2
2
  import {validateBirthDate} from './date-of-birth.mjs';
3
3
  import {expectedExitDetails} from './exit-details.mjs';
4
4
  import {createEntryCorrector,correctionFailureDetails} from './entry-correction.mjs';
5
+ import {createEntryRowRegistrar} from './entry-row-registration.mjs';
6
+ import {createHouseholdEnrollmentDiscovery} from './household-enrollment-discovery.mjs';
7
+ import {workflowErrorCode} from './workflow-error-codes.mjs';
5
8
  import {createHash} from 'node:crypto';
6
9
  import {readProgramCatalog,encodeAnswers,encodeNewSubassessmentRow,toItems,parseRestInstant} from '../dist/esm/index.js';
7
10
 
@@ -10,12 +13,13 @@ const positive=v=>Number.isSafeInteger(v)&&v>0;
10
13
  const entryExitTypes=['HUD','Basic','Standard','RHY','VA','Basic Center Program Entry/Exit','PATH','Quick Call','Transitional Living Program Entry/Exit'];
11
14
  const canonical=v=>JSON.stringify(v,(_,x)=>object(x)?Object.fromEntries(Object.keys(x).sort().map(k=>[k,x[k]])):x);
12
15
  const hash=v=>createHash('sha256').update(canonical(v)).digest('hex');
13
- const fail=message=>{throw new EnrollmentWorkflowError(message);};
16
+ const fail=(message,options)=>{throw new EnrollmentWorkflowError(message,{committed:false,...options});};
14
17
  export class EnrollmentWorkflowError extends Error {
15
18
  constructor(reason,options){
16
19
  super(`Enrollment workflow: ${reason}`,options);this.name='EnrollmentWorkflowError';this.reason=reason;
17
20
  if(options?.step!==undefined)this.step=options.step;
18
- if(options?.code!==undefined)this.code=options.code;
21
+ this.code=options?.code??workflowErrorCode(reason);
22
+ this.committed=options?.committed??'unknown';
19
23
  if(options?.fields!==undefined)this.fields=[...options.fields];
20
24
  const status=options?.status??options?.cause?.status;
21
25
  if(Number.isInteger(status)&&status>=100&&status<=599)this.status=status;
@@ -68,7 +72,7 @@ function compile(definition,input,{yesNoWrites,writeLabel}={}){
68
72
  const covered=new Set([...Object.keys(answers),...Object.keys(excluded),...carry,...compiledRows.map(r=>r.parentField),...compiledEnds.map(r=>r.parentField)]);
69
73
  for(const q of definition.questions){
70
74
  if(['blank','label'].includes(q.questionType))continue;
71
- if(!covered.has(q.fieldName))fail(`uncovered assessment field ${q.fieldName}`);
75
+ if(!covered.has(q.fieldName))fail(`uncovered assessment field ${q.fieldName}`,{code:'ASSESSMENT_FIELD_UNCOVERED',fields:[q.fieldName??String(q.questionId)]});
72
76
  if((q.fieldName in excluded||carry.includes(q.fieldName))&&(q.fieldName in answers||compiledRows.some(r=>r.parentField===q.fieldName)||compiledEnds.some(r=>r.parentField===q.fieldName)))fail('conflicting field coverage');
73
77
  if(q.fieldName in excluded&&carry.includes(q.fieldName))fail('conflicting field coverage');
74
78
  }
@@ -81,12 +85,12 @@ function compile(definition,input,{yesNoWrites,writeLabel}={}){
81
85
  if(yesNo.length)for(const q of definition.questions){
82
86
  if(!q.mandatory||['blank','label','sub_assessment'].includes(q.questionType)||yesNo.some(y=>y.virt_field_name===q.fieldName))continue;
83
87
  // Only mandatory lookups were verified live; this also keeps DOB inside its own helper.
84
- if(q.questionType!=='lookup')fail(`standard yes/no writes support mandatory lookups only; ${q.fieldName} is ${q.questionType}`);
88
+ if(q.questionType!=='lookup')fail(`standard yes/no writes support mandatory lookups only; ${q.fieldName} is ${q.questionType}`,{code:'UNSUPPORTED_MANDATORY_FIELD_TYPE',fields:[q.fieldName??String(q.questionId)]});
85
89
  const base={question_id:q.questionId,question_type:q.questionType,virt_field_name:q.fieldName};
86
90
  if(q.fieldName in answers){
87
91
  const [label]=encodeAnswers(definition.questions,{[q.fieldName]:answers[q.fieldName]},{endpoint:'standalone',writeLabel});
88
92
  const [stored]=encodeAnswers(definition.questions,{[q.fieldName]:answers[q.fieldName]},{endpoint:'mobile'});
89
- if(!label||!stored)fail(`mandatory answer ${q.fieldName} has no standard encoding`);
93
+ if(!label||!stored)fail(`mandatory answer ${q.fieldName} has no standard encoding`,{code:'ANSWER_ENCODING_UNAVAILABLE',fields:[q.fieldName??String(q.questionId)]});
90
94
  mandatory.push({...base,answer_value:label.answer_value,expected:stored.answer_value});
91
95
  }else if(q.fieldName in excluded)mandatory.push({...base,answer_value:'',expected:''});
92
96
  else if(carry.includes(q.fieldName))mandatory.push({...base,carry:true});
@@ -111,7 +115,18 @@ async function enrollments(api,clientId,{enrollmentSession,writeAttempted=false}
111
115
  /** Explicit orchestration for an existing client. All writers must share the durable store.
112
116
  * No automatic retries of uncertain REST writes. This is not a cross-system transaction. */
113
117
  export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollmentSession,exitUi,memberEntryDatePort,birthDates,assessmentLocations={},yesNoWrites='mobile',writeLabel}){
114
- const tenantScope=apiScope(api, discovery, helpers, enrollmentSession, exitUi);
118
+ let tenantScope;try{tenantScope=apiScope(api, discovery, helpers, enrollmentSession, exitUi, writeLabel);}catch(cause){throw new EnrollmentWorkflowError(cause instanceof Error?cause.message:'invalid workflow configuration',{code:'WORKFLOW_CONFIGURATION_INVALID',committed:false,cause});}
119
+ const execute=async(operation,action)=>{
120
+ const state={attempted:false};
121
+ try{return await action(state);}catch(cause){
122
+ const error=cause instanceof EnrollmentWorkflowError?cause:new EnrollmentWorkflowError(cause instanceof Error?cause.message:'upstream workflow operation failed',{code:cause?.code==='SESSION_INTERRUPTED'?'SESSION_INTERRUPTED':cause?.kind==='validation'?'WORKFLOW_VALIDATION_FAILED':'WORKFLOW_UPSTREAM_FAILURE',...(Array.isArray(cause?.fields)&&cause.fields.every(f=>typeof f==='string')?{fields:cause.fields}:{}),cause});
123
+ if(cause?.code==='SESSION_INTERRUPTED')error.reauthenticated=cause.reauthenticated;
124
+ error.operation=operation;
125
+ error.committed=state.attempted||cause instanceof EnrollmentReadError&&cause.committed==='unknown'||cause?.code==='ENTRY_CORRECTION_UNCERTAIN'?'unknown':false;
126
+ if(operation==='resolveEntryCorrection')error.originalCommitted='unknown';
127
+ throw error;
128
+ }
129
+ };
115
130
  if(typeof discovery?.getProviderAssessments!=='function'||typeof helpers?.createInterimUpdate!=='function'||!store)fail('REST API, discovery, enrollment helpers and durable store required');
116
131
  if(!['mobile','standard'].includes(yesNoWrites)||yesNoWrites==='standard'&&(typeof writeLabel!=='function'||typeof api.assessments?.create!=='function'))fail('yesNoWrites must be mobile, or standard with writeLabel and assessments.create');
117
132
  if(!object(assessmentLocations))fail('assessment locations must be a map by Entry/Exit type');
@@ -123,7 +138,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
123
138
  const locationsByType={HUD:{entry:'showHudEntry',interim:'showHudInterim',exit:'showHudExit'},VA:{entry:'showVaEntry',interim:'showVaInterim',exit:'showVaExit'},...structuredClone(assessmentLocations)};
124
139
  const discover=async(input,includeExit=false)=>{
125
140
  fields(input,['clientId','providerId','type','entryDate']);if(!positive(input.clientId)||!positive(input.providerId)||!entryExitTypes.includes(input.type))fail('client, provider and supported Entry/Exit type required');const at=date(input.entryDate);
126
- const locations=locationsByType[input.type];if(!locations||includeExit&&!locations.exit)fail(`assessment locations required for ${input.type}${includeExit?' including exit':''}`);
141
+ const locations=locationsByType[input.type];if(!locations||includeExit&&!locations.exit)fail(`assessment locations required for ${input.type}${includeExit?' including exit':''}`,{code:'ASSESSMENT_LOCATIONS_REQUIRED'});
127
142
  const client=toItems(await api.clients.get({path:{clientId:input.clientId}}));if(client.length!==1||client[0].clientId!==input.clientId||client[0].isActive===false)fail('active client required');
128
143
  const catalog=await readProgramCatalog(api);const providers=catalog.providers.filter(p=>p.providerId===input.providerId);if(providers.length!==1)fail('active operational program required');
129
144
  const configuration=await discovery.getProviderAssessments({providerId:input.providerId});if(configuration.providerId!==input.providerId)fail('provider discovery mismatch');
@@ -139,7 +154,10 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
139
154
  const scope=clientId=>JSON.stringify([tenantScope,'enrollment-workflow',clientId]);
140
155
  const reservationScope=clientId=>JSON.stringify([tenantScope,'household-workflow-reservation',clientId]);
141
156
  const {correctEntry,resolveEntryCorrection}=createEntryCorrector({api,store,enrollmentSession,writeLabel,discover,scope,reservationScope,makeError:(reason,options)=>new EnrollmentWorkflowError(reason,options)});
157
+ const registerEntryRows=createEntryRowRegistrar({api,store,enrollmentSession,discover,scope,reservationScope,makeError:(reason,options)=>new EnrollmentWorkflowError(reason,options)});
158
+ const discoverHouseholdEnrollment=createHouseholdEnrollmentDiscovery({api,enrollmentSession,makeError:(reason,options)=>new EnrollmentWorkflowError(reason,options)});
142
159
  const run=async(kind,raw,internal={})=>{
160
+ const operation=internal.operation??{attempted:false};
143
161
  const standaloneExit=kind==='exit'&&internal.standaloneExit===true;
144
162
  fields(raw,kind==='entry'?['key','clientId','providerId','type','entryDate','assessment']:['key','enrollmentKey','clientId',kind==='exit'?'exitDate':'reviewDate','assessment',...(standaloneExit?['destination','reasonForLeaving','destinationDetails','reasonForLeavingOther']:[])]);
145
163
  const input=structuredClone(raw);key(input.key);if(!positive(input.clientId))fail('client ID required');
@@ -153,7 +171,9 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
153
171
  return locked(scope(input.clientId),async()=>{
154
172
  const reservations=await store.withLock(reservationScope(input.clientId),()=>store.read(reservationScope(input.clientId)));
155
173
  if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'&&r.owner!==internal.owner))fail('unfinished household workflow must be reconciled first');
174
+ const inheritedAttempt=operation.attempted;operation.attempted=true;
156
175
  const records=await store.read(scope(input.clientId));if(!Array.isArray(records)||records.some(r=>!object(r)||r.version!==1||!object(r.steps)))fail('invalid workflow journal');
176
+ operation.attempted=inheritedAttempt||records.some(r=>r.key===input.key&&Object.keys(r.steps).length>0);
157
177
  if(records.some(r=>r.stage!=='verified'&&!(r.kind==='correction'&&r.stage==='rejected')&&r.key!==input.key))fail('another unfinished workflow must be reconciled first');
158
178
  const matches=records.filter(r=>r.key===input.key);if(matches.length>1)fail('duplicate journal keys');let record=matches[0];
159
179
  const intentHash=hash({kind,input,...(internal.owner?{household:internal.owner}:{})});if(record&&record.intentHash!==intentHash)fail('key conflicts with previous intent');
@@ -226,6 +246,17 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
226
246
  // that instant; one second later returns them. Only the yes/no step needs this.
227
247
  const readSaved=async()=>questions(await api.assessments.get({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:new Date(Date.parse(at)+1000).toISOString()}}));
228
248
  const initial=await read();
249
+ if(!record&&plan.scalar.length){
250
+ const saved=await readSaved();
251
+ const existing=plan.scalar.map(q=>({q,row:saved.find(r=>r.virt_field_name===q.virt_field_name)}));
252
+ const complete=existing.every(({q,row})=>row&&sameAnswer(q,row.answer_value)&&restDate(row.date_effective)===restDate(at));
253
+ if(!complete){
254
+ const unavailable=existing.filter(({q,row})=>!row||row.question_type!==q.question_type||row.date_effective!=null&&(!Number.isFinite(restDate(row.date_effective))||restDate(row.date_effective)>restDate(at))||row.date_effective==null&&row.answer_value!=null&&row.answer_value!=='').map(({q})=>q.virt_field_name);
255
+ if(unavailable.length)fail('mobile scalar preflight unavailable',{code:'MOBILE_PREFLIGHT_UNAVAILABLE',fields:unavailable});
256
+ const collisions=existing.filter(({row})=>restDate(row.date_effective)===restDate(at)).map(({q})=>q.virt_field_name);
257
+ if(collisions.length)fail('mobile scalar answer already exists at the effective date',{code:'MOBILE_ANSWER_EXISTS',fields:collisions});
258
+ }
259
+ }
229
260
  if(expectedHead===input.clientId&&headQuestion)checkHeadAnswer(proposedHead?.answer_value??initial.find(q=>q.virt_field_name==='HUD_RELATIONTOHOH')?.answer_value);
230
261
  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');}
231
262
  // Resend mandatory answers with the standard yes/no write. A blank write must not hide a recorded value.
@@ -236,11 +267,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
236
267
  if(m.carry){
237
268
  const question=definition.questions.find(q=>q.fieldName===m.virt_field_name),options=question.options.filter(o=>String(o.id)===String(recorded));
238
269
  const label=options.length===1?writeLabel(question,options[0]):undefined;
239
- if(!label)fail(`carried mandatory answer ${m.virt_field_name} has no verified write label`);
270
+ if(!label)fail(`carried mandatory answer ${m.virt_field_name} has no verified write label`,{code:'LOOKUP_WRITE_LABEL_UNAVAILABLE',fields:[m.virt_field_name]});
240
271
  if(m.virt_field_name==='HUD_RELATIONTOHOH')checkHeadAnswer(recorded);
241
272
  return {question_id:m.question_id,question_type:m.question_type,virt_field_name:m.virt_field_name,answer_value:label,expected:String(recorded)};
242
273
  }
243
- if(m.expected===''&&recorded!==null&&recorded!==undefined&&recorded!=='')fail(`not-applicable mandatory field ${m.virt_field_name} has a recorded value; answer it explicitly`);
274
+ if(m.expected===''&&recorded!==null&&recorded!==undefined&&recorded!=='')fail(`not-applicable mandatory field ${m.virt_field_name} has a recorded value; answer it explicitly`,{code:'MANDATORY_CLEAR_CONFLICT',fields:[m.virt_field_name]});
244
275
  return m;
245
276
  });
246
277
  // Validate closure targets before creating enrollment/review or submitting any answers.
@@ -274,6 +305,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
274
305
  if(previous)throw new EnrollmentWorkflowError(previous.state==='verified'?'verified step changed':'uncertain write requires reconciliation',{step:name});
275
306
  record.steps[name]={state:'attempted'};await persist();
276
307
  let writeError;
308
+ operation.attempted=true;
277
309
  try{await submit(result);}catch(error){writeError=error;}
278
310
  // A write response alone never establishes success or absence. Preserve its
279
311
  // diagnostic cause in memory while reconciliation remains authoritative.
@@ -299,6 +331,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
299
331
  else {const found=await findEnrollment();if(!found.found)fail('saved enrollment missing');record.enrollmentId=entry.enrollmentId;}
300
332
  await persist();
301
333
  if(kind==='interim'){
334
+ if(!internal.readReview)operation.attempted=true;
302
335
  // The GWT helper has its own journal and authoritative reads; it owns uncertain-write reconciliation.
303
336
  const review=internal.readReview?await internal.readReview({clientId:input.clientId,enrollmentId:record.enrollmentId}):await helpers.createInterimUpdate({clientId:input.clientId,enrollmentId:record.enrollmentId,clientIds:[input.clientId],reviewDate:at});
304
337
  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();
@@ -361,7 +394,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
361
394
  const known=record.steps.birthDate.value;
362
395
  result=await birthDates.verify({...dobContext,dateOfBirth:known.dateOfBirth,effectiveDate:known.effectiveDate,allowLater:true});
363
396
  }else if(plan.dateOfBirth!==undefined){
364
- result=await birthDates.update({...dobContext,key:'enrollment-dob:'+hash({key:input.key,kind,clientId:input.clientId}),dateOfBirth:plan.dateOfBirth,effectiveDate:at,...(kind==='entry'?{entry:{enrollmentId:record.enrollmentId,assessmentName:discovered.entry.assessment.name}}:{})});
397
+ operation.attempted=true;result=await birthDates.update({...dobContext,key:'enrollment-dob:'+hash({key:input.key,kind,clientId:input.clientId}),dateOfBirth:plan.dateOfBirth,effectiveDate:at,...(kind==='entry'?{entry:{enrollmentId:record.enrollmentId,assessmentName:discovered.entry.assessment.name}}:{})});
365
398
  }else result=await birthDates.verify({...dobContext,dateOfBirth:carriedBirthDate.dateOfBirth,effectiveDate:carriedBirthDate.effectiveDate,allowLater:true});
366
399
  record.steps.birthDate={state:'verified',value:{clientId:input.clientId,dateOfBirth:result.dateOfBirth,effectiveDate:result.effectiveDate}};await persist();
367
400
  }
@@ -373,6 +406,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
373
406
  const memberKey=(key,clientId)=>'household:'+hash({key,clientId});
374
407
  const withClients=(ids,work,index=0)=>index===ids.length?work():store.withLock(scope(ids[index]),()=>withClients(ids,work,index+1));
375
408
  const adopt=async(household,raw)=>{
409
+ if(household&&raw&&Object.hasOwn(raw,'headEnrollmentId')){
410
+ fields(raw,['key','headClientId','headEnrollmentId']);key(raw.key);
411
+ const found=await discoverHouseholdEnrollment({headClientId:raw.headClientId,headEnrollmentId:raw.headEnrollmentId});
412
+ raw={key:raw.key,headClientId:found.headClientId,householdId:found.householdId,members:found.members};
413
+ }
376
414
  fields(raw,household?['key','householdId','headClientId','members']:['key','clientId','enrollmentId']);
377
415
  const input=structuredClone(raw);key(input.key);
378
416
  if(typeof enrollmentSession?.readEnrollment!=='function')fail('authoritative enrollment session required for adoption');
@@ -448,7 +486,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
448
486
  });
449
487
  });
450
488
  };
451
- const householdRun=async(kind,raw)=>{
489
+ const householdRun=async(kind,raw,operation={attempted:false})=>{
452
490
  if(typeof enrollmentSession?.readEnrollment!=='function'||typeof helpers.addHouseholdMembers!=='function'||typeof api.households?.get!=='function')fail('household API and authoritative enrollment session required');
453
491
  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']:[])]);
454
492
  const input=structuredClone(raw);key(input.key);
@@ -463,8 +501,10 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
463
501
  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);}
464
502
  // Read the cohort under its journal lock, then acquire all client locks in order.
465
503
  // Recheck under both locks so concurrent membership growth cannot escape protection.
504
+ operation.attempted=true;
466
505
  const prior=await store.withLock(householdScope(input.headClientId),()=>store.read(householdScope(input.headClientId)));
467
506
  if(!Array.isArray(prior))fail('invalid household journal');
507
+ operation.attempted=prior.some(r=>r.key===input.key);
468
508
  const lockedIds=[...new Set([input.headClientId,...ids,...prior.flatMap(r=>r.clientIds??[])])].sort((a,b)=>a-b);
469
509
  return withClients(lockedIds,()=>store.withLock(householdScope(input.headClientId),async()=>{
470
510
  const records=await store.read(householdScope(input.headClientId));
@@ -532,7 +572,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
532
572
  const toMember=m=>['entry','addition'].includes(kind)?{key:memberKey(input.key,m.clientId),clientId:m.clientId,providerId:context.providerId,type:context.type,entryDate:context.entryDate,assessment:m.assessment}:{key:memberKey(input.key,m.clientId),enrollmentKey:memberKey(catalog[m.clientId].entryKey,m.clientId),clientId:m.clientId,...(kind==='exit'?{exitDate:input.exitDate}:{reviewDate:input.reviewDate}),assessment:m.assessment};
533
573
  // Validate every member before any record mutation. The same client locks cover
534
574
  // preflight and execution, including overlaps with the single-client workflow.
535
- const preflight=[];for(const member of input.members)preflight.push(await run(kind==='addition'?'entry':kind,toMember(member),{locked:true,owner,headClientId:input.headClientId,preflight:true,...(kind==='addition'?{pinSchema:true}:{})}));
575
+ const preflight=[];for(const member of input.members)preflight.push(await run(kind==='addition'?'entry':kind,toMember(member),{locked:true,owner,operation,headClientId:input.headClientId,preflight:true,...(kind==='addition'?{pinSchema:true}:{})}));
536
576
  if(kind==='addition'&&record?.entrySchemas&&canonical(record.entrySchemas)!==canonical(preflight))fail('additional member assessment configuration changed');
537
577
  if(kind==='exit'&&record?.exitSchemas&&canonical(record.exitSchemas)!==canonical(preflight))fail('exit assessment configuration changed');
538
578
  const wasVerified=record?.stage==='verified';
@@ -580,14 +620,14 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
580
620
  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');
581
621
  }
582
622
  const head=input.members.find(m=>m.clientId===input.headClientId);
583
- const result=await run('entry',toMember(head),{locked:true,owner,headClientId:input.headClientId,findEnrollment:findMember(head.clientId)});record.members[head.clientId]=result;await persist();
623
+ const result=await run('entry',toMember(head),{locked:true,owner,operation,headClientId:input.headClientId,findEnrollment:findMember(head.clientId)});record.members[head.clientId]=result;await persist();
584
624
  // Reject unexpected members before the attachment helper is allowed to write.
585
625
  await readTarget({clientId:head.clientId,enrollmentId:result.enrollmentId},{partial:!record.attached});
586
626
  record.attachmentAttempted=true;await persist();
587
- const attached=await helpers.addHouseholdMembers({clientId:head.clientId,enrollmentId:result.enrollmentId,memberClientIds:ids.filter(id=>id!==head.clientId)});
627
+ operation.attempted=true;const attached=await helpers.addHouseholdMembers({clientId:head.clientId,enrollmentId:result.enrollmentId,memberClientIds:ids.filter(id=>id!==head.clientId)});
588
628
  const snapshot=validateSnapshot(attached.snapshot,{clientId:head.clientId,enrollmentId:result.enrollmentId});
589
629
  for(const m of snapshot.members)record.members[m.clientId]??={clientId:m.clientId,enrollmentId:m.enrollmentId};record.attached=true;await persist();
590
- for(const member of input.members.filter(m=>m.clientId!==head.clientId)){record.members[member.clientId]=await run('entry',toMember(member),{locked:true,owner,headClientId:input.headClientId,findEnrollment:findMember(member.clientId)});await persist();}
630
+ for(const member of input.members.filter(m=>m.clientId!==head.clientId)){record.members[member.clientId]=await run('entry',toMember(member),{locked:true,owner,operation,headClientId:input.headClientId,findEnrollment:findMember(member.clientId)});await persist();}
591
631
  }else if(kind==='addition'){
592
632
  if(!record.anchor){
593
633
  for(const clientId of Object.keys(catalog).map(Number).sort((a,b)=>a-b)){
@@ -601,7 +641,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
601
641
  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');
602
642
  }
603
643
  record.attachmentAttempted=true;await persist();
604
- const attached=await helpers.addHouseholdMembers({...record.anchor,memberClientIds:ids});
644
+ operation.attempted=true;const attached=await helpers.addHouseholdMembers({...record.anchor,memberClientIds:ids});
605
645
  validateSnapshot(attached.snapshot,record.anchor);
606
646
  for(const clientId of ids){const found=attached.snapshot.members.find(m=>m.clientId===clientId);if(record.members[clientId]?.enrollmentId&&record.members[clientId].enrollmentId!==found.enrollmentId)fail('added enrollment identity changed');record.members[clientId]??={clientId,enrollmentId:found.enrollmentId};}
607
647
  record.attached=true;await persist();await preserve();
@@ -619,13 +659,13 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
619
659
  const change={...target,providerId:context.providerId,type:context.type,entryDate:input.entryDate,cohort};
620
660
  await memberEntryDatePort.validateMemberEntryDate(change);
621
661
  record.entryDates[clientId]='attempted';record.entryDateInputs[clientId]=change;await persist();
622
- try{await memberEntryDatePort.saveMemberEntryDate(change);}catch{/* Resolve only through readback; never replay the date update. */}
662
+ operation.attempted=true;try{await memberEntryDatePort.saveMemberEntryDate(change);}catch{/* Resolve only through readback; never replay the date update. */}
623
663
  }
624
664
  if(record.entryDates[clientId]==='attempted'&&record.entryDateInputs[clientId])await memberEntryDatePort.validateMemberEntryDate(record.entryDateInputs[clientId]);
625
665
  const fresh=await readTarget(target);if(!wasVerified&&fresh.exits?.filter(e=>e.clientId===clientId&&e.enrollmentId===target.enrollmentId&&e.projectExitDate===null).length!==1)fail('new member must remain enrolled');
626
666
  await preserve();record.entryDates[clientId]='verified';await persist();
627
667
  }
628
- for(const member of input.members){record.members[member.clientId]=await run('entry',toMember(member),{locked:true,owner,headClientId:input.headClientId,findEnrollment:findMember(member.clientId)});await persist();}
668
+ for(const member of input.members){record.members[member.clientId]=await run('entry',toMember(member),{locked:true,owner,operation,headClientId:input.headClientId,findEnrollment:findMember(member.clientId)});await persist();}
629
669
  }else if(kind==='interim'){
630
670
  // Verify all linked enrollments before making the single household review call.
631
671
  const existingReviews=[];
@@ -640,7 +680,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
640
680
  if(existingReviews.some(Boolean)&&existingReviews.some(r=>!r))fail('partial household review requires reconciliation');
641
681
  if(existingReviews[0]&&new Set(existingReviews.map(r=>r.reviewId)).size!==ids.length)fail('duplicate household review IDs');
642
682
  const target={clientId:ids[0],enrollmentId:catalog[ids[0]].enrollmentId};
643
- const review=await helpers.createInterimUpdate({...target,clientIds:ids,reviewDate:input.reviewDate});validateSnapshot(review.snapshot,target);
683
+ operation.attempted=true;const review=await helpers.createInterimUpdate({...target,clientIds:ids,reviewDate:input.reviewDate});validateSnapshot(review.snapshot,target);
644
684
  const readReview=async target=>{
645
685
  const snapshot=await readTarget(target),reviews=snapshot.reviews.filter(r=>r.type==='Update'&&restDate(r.reviewDate)===restDate(input.reviewDate));
646
686
  if(reviews.length!==1||!positive(reviews[0].reviewId)||canonical([...reviews[0].clientIds].sort((a,b)=>a-b))!==canonical(ids))fail('household review membership mismatch');
@@ -650,7 +690,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
650
690
  // Pin every member's distinct review ID before writing any member's answers.
651
691
  for(const clientId of ids){const enrollmentId=catalog[clientId].enrollmentId,{snapshot}=await readReview({clientId,enrollmentId});record.members[clientId]??={clientId,enrollmentId};record.members[clientId].reviewId=snapshot.reviews.find(r=>r.type==='Update'&&restDate(r.reviewDate)===restDate(input.reviewDate)).reviewId;await persist();}
652
692
  if(new Set(ids.map(id=>record.members[id].reviewId)).size!==ids.length)fail('duplicate household review IDs');
653
- for(const member of input.members){record.members[member.clientId]=await run('interim',toMember(member),{locked:true,owner,headClientId:input.headClientId,findEnrollment:findMember(member.clientId),readReview});await persist();}
693
+ for(const member of input.members){record.members[member.clientId]=await run('interim',toMember(member),{locked:true,owner,operation,headClientId:input.headClientId,findEnrollment:findMember(member.clientId),readReview});await persist();}
654
694
  }else if(kind==='exit'){
655
695
  const target={clientId:ids[0],enrollmentId:catalog[ids[0]].enrollmentId};
656
696
  const normalizeLabel=value=>typeof value==='string'?value.replace(/\s*\(HUD\)\s*$/,'').trim():value;
@@ -681,7 +721,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
681
721
  const exitInput={...target,clientIds:ids,exitDate:input.exitDate,destination:input.destination,reasonForLeaving:input.reasonForLeaving,...(input.destinationDetails?{destinationDetails:input.destinationDetails}:{}),...(input.reasonForLeavingOther?{reasonForLeavingOther:input.reasonForLeavingOther}:{})};
682
722
  await exitUi.validateExit(exitInput);
683
723
  record.exitStep='attempted';await persist();
684
- try{await exitUi.saveExit(exitInput);}catch{/* Only authoritative readback establishes success. Never replay an uncertain save. */}
724
+ operation.attempted=true;try{await exitUi.saveExit(exitInput);}catch{/* Only authoritative readback establishes success. Never replay an uncertain save. */}
685
725
  observed=await inspect();if(!observed.found)fail('uncertain household exit requires reconciliation');
686
726
  }
687
727
  await validateHeadCoverage();await preserve();
@@ -689,7 +729,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
689
729
  const readExit=async target=>{
690
730
  const result=await inspect();if(!result.found)fail('verified exit missing');return result.exits.find(e=>e.clientId===target.clientId&&e.enrollmentId===target.enrollmentId);
691
731
  };
692
- for(const member of input.members){record.members[member.clientId]=await run('exit',toMember(member),{locked:true,owner,headClientId:input.headClientId,findEnrollment:findMember(member.clientId),readExit});await persist();}
732
+ for(const member of input.members){record.members[member.clientId]=await run('exit',toMember(member),{locked:true,owner,operation,headClientId:input.headClientId,findEnrollment:findMember(member.clientId),readExit});await persist();}
693
733
  }
694
734
  if(kind==='exit'||kind==='addition')await preserve();
695
735
  await validateHeadCoverage();
@@ -698,13 +738,16 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
698
738
  }));
699
739
  };
700
740
  return {
701
- correctEntry,
702
- resolveEntryCorrection,
703
- adoptEnrollment:input=>adopt(false,input),adoptHouseholdEnrollment:input=>adopt(true,input),
704
- addHouseholdMembers:input=>householdRun('addition',input),
705
- createHouseholdEnrollment:input=>householdRun('entry',input),createHouseholdInterim:input=>householdRun('interim',input),exitHousehold:input=>householdRun('exit',input),
706
- discoverExit:input=>discover(input,true),
741
+ correctEntry:input=>execute('correctEntry',operation=>correctEntry(input,operation)),
742
+ registerEntryRows:input=>execute('registerEntryRows',()=>registerEntryRows(input)),
743
+ discoverHouseholdEnrollment:input=>execute('discoverHouseholdEnrollment',()=>discoverHouseholdEnrollment(input)),
744
+ resolveEntryCorrection:input=>execute('resolveEntryCorrection',()=>resolveEntryCorrection(input)),
745
+ adoptEnrollment:input=>execute('adoptEnrollment',()=>adopt(false,input)),adoptHouseholdEnrollment:input=>execute('adoptHouseholdEnrollment',()=>adopt(true,input)),
746
+ addHouseholdMembers:input=>execute('addHouseholdMembers',operation=>householdRun('addition',input,operation)),
747
+ createHouseholdEnrollment:input=>execute('createHouseholdEnrollment',operation=>householdRun('entry',input,operation)),createHouseholdInterim:input=>execute('createHouseholdInterim',operation=>householdRun('interim',input,operation)),exitHousehold:input=>execute('exitHousehold',operation=>householdRun('exit',input,operation)),
748
+ discoverExit:input=>execute('discoverExit',()=>discover(input,true)),
707
749
  async getHouseholdStatus(input){
750
+ return execute('getHouseholdStatus',async()=>{
708
751
  fields(input,['headClientId','key']);if(!positive(input.headClientId))fail('head client ID required');key(input.key);
709
752
  return store.withLock(householdScope(input.headClientId),async()=>{
710
753
  const records=await store.read(householdScope(input.headClientId));if(!Array.isArray(records))fail('invalid household journal');
@@ -712,9 +755,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
712
755
  if(record.version!==1||!object(record.members))fail('invalid household journal');
713
756
  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]??{})}))};
714
757
  });
758
+ });
715
759
  },
716
- discover,createEnrollment:input=>run('entry',input),createInterim:input=>run('interim',input),exitEnrollment:input=>run('exit',input,{standaloneExit:true}),
760
+ discover:input=>execute('discover',()=>discover(input)),createEnrollment:input=>execute('createEnrollment',operation=>run('entry',input,{operation})),createInterim:input=>execute('createInterim',operation=>run('interim',input,{operation})),exitEnrollment:input=>execute('exitEnrollment',operation=>run('exit',input,{standaloneExit:true,operation})),
717
761
  async getStatus(input){
762
+ return execute('getStatus',async()=>{
718
763
  fields(input,['clientId','key']);if(!positive(input.clientId))fail('client ID required');key(input.key);
719
764
  return store.withLock(scope(input.clientId),async()=>{
720
765
  const records=await store.read(scope(input.clientId));if(!Array.isArray(records))fail('invalid workflow journal');
@@ -722,6 +767,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
722
767
  if(r.version!==1||!object(r.steps))fail('invalid workflow journal');
723
768
  return {key:r.key,kind:r.kind,stage:r.stage,assessmentCode:r.assessmentCode,...(r.enrollmentId?{enrollmentId:r.enrollmentId}:{}),...(r.reviewId?{reviewId:r.reviewId}:{}),...(r.projectExitDate?{projectExitDate:r.projectExitDate,exitSubgroupId:r.exitSubgroupId}:{}),...(r.resolution?{resolution:structuredClone(r.resolution)}:{}),recordsetIds:structuredClone(r.rows),steps:Object.entries(r.steps).map(([name,step])=>({name,state:step.state,...(step.failure?{failure:correctionFailureDetails(step.failure)}:{})}))};
724
769
  });
770
+ });
725
771
  },
726
772
  };
727
773
  }
@@ -50,7 +50,7 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
50
50
  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');
51
51
  return rows;
52
52
  };
53
- const run=async(mode,raw)=>{
53
+ const run=async(mode,raw,operation={attempted:false})=>{
54
54
  const resolving=mode==='resolve';
55
55
  fields(raw,resolving?['key','enrollmentKey','clientId','evidenceReference','confirmNotApplied']:['key','enrollmentKey','clientId','answers','clear','subassessments']);
56
56
  const {confirmNotApplied,...serializable}=raw;
@@ -61,8 +61,10 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
61
61
  return store.withLock(scope(input.clientId),async()=>{
62
62
  const reservations=await store.withLock(reservationScope(input.clientId),()=>store.read(reservationScope(input.clientId)));
63
63
  if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'))fail('unfinished household workflow must be reconciled first');
64
+ if(!resolving)operation.attempted=true;
64
65
  const records=await store.read(scope(input.clientId));
65
66
  if(!Array.isArray(records)||records.some(r=>!object(r)||r.version!==1||!object(r.steps)))fail('invalid workflow journal');
67
+ if(!resolving)operation.attempted=records.some(r=>r.key===input.key&&Object.keys(r.steps).length>0);
66
68
  if(records.some(r=>r.stage!=='verified'&&!(r.kind==='correction'&&r.stage==='rejected')&&r.key!==input.key))fail('another unfinished workflow must be reconciled first');
67
69
  const entries=records.filter(r=>r.kind==='entry'&&r.key===input.enrollmentKey&&r.stage==='verified');
68
70
  if(entries.length!==1)fail('verified enrollment workflow required');const entry=entries[0];
@@ -162,7 +164,7 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
162
164
  for(const patch of input.subassessments??[]){
163
165
  fields(patch,['key','parentField','answers','clear']);key(patch.key);
164
166
  const id=entry.rows[patch.key],parent=definition.questions.find(q=>q.fieldName===patch.parentField&&q.questionType==='sub_assessment');
165
- 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);
167
+ if(!positive(id)||ids.has(id)||!parent||!observed.rows[parent.fieldName]?.[id]||entry.rowParents?.[patch.key]&&entry.rowParents[patch.key]!==patch.parentField)fail('correction requires an existing entry row key and matching parent');ids.add(id);
166
168
  const cells=encode(parent.children,patch.answers??{},patch.clear??[],'mobile');if(!cells.length)fail('empty row correction');
167
169
  rowPlans.push({id,parentField:parent.fieldName,questionId:parent.questionId,cells});
168
170
  }
@@ -184,7 +186,7 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
184
186
  if(record.steps[name])throw uncertain();
185
187
  if(!await inspect(null))fail('entry changed since correction preflight');
186
188
  await verifyEnrollment();record.steps[name]={state:'attempted'};await persist();let cause;
187
- try{await submit();}catch(e){
189
+ operation.attempted=true;try{await submit();}catch(e){
188
190
  cause=e;const failure=correctionFailureDetails(e);
189
191
  if(Object.keys(failure).length){record.steps[name].failure=failure;await persist();}
190
192
  }
@@ -201,5 +203,5 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
201
203
  record.stage='verified';await persist();return result('verified');
202
204
  });
203
205
  };
204
- return {correctEntry:raw=>run('correct',raw),resolveEntryCorrection:raw=>run('resolve',raw)};
206
+ return {correctEntry:(raw,operation)=>run('correct',raw,operation),resolveEntryCorrection:raw=>run('resolve',raw)};
205
207
  }
@@ -0,0 +1,63 @@
1
+ import {createHash} from 'node:crypto';
2
+ import {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
+
9
+ /** Registers caller-selected existing identities locally; never writes an assessment. */
10
+ export function createEntryRowRegistrar({api,store,enrollmentSession,discover,scope,reservationScope,makeError}){
11
+ const fail=(code,reason)=>{throw makeError(reason,{code,committed:false});};
12
+ const key=v=>typeof v==='string'&&!!v.trim()&&v.length<=200;
13
+ return async raw=>{
14
+ if(!object(raw)||Object.keys(raw).some(k=>!['key','enrollmentKey','clientId','rows'].includes(k))||!key(raw.key)||!key(raw.enrollmentKey)||!positive(raw.clientId)||!Array.isArray(raw.rows)||!raw.rows.length)fail('ROW_REGISTRATION_INPUT_INVALID','valid registration key, entry key, client and explicit rows required');
15
+ if(typeof enrollmentSession?.readEnrollment!=='function')fail('ROW_REGISTRATION_READER_REQUIRED','authoritative enrollment reader required');
16
+ const input=structuredClone(raw);input.rows.sort((a,b)=>String(a?.key).localeCompare(String(b?.key)));
17
+ const keys=new Set(),ids=new Set();
18
+ for(const row of input.rows){
19
+ if(!object(row)||Object.keys(row).some(k=>!['key','parentField','recordsetId'].includes(k))||!key(row.key)||!key(row.parentField)||!positive(row.recordsetId)||keys.has(row.key)||ids.has(row.recordsetId))fail('ROW_REGISTRATION_INPUT_INVALID','unique row keys, parents and positive recordset IDs required');
20
+ keys.add(row.key);ids.add(row.recordsetId);
21
+ }
22
+ return store.withLock(scope(input.clientId),async()=>{
23
+ const reservations=await store.withLock(reservationScope(input.clientId),()=>store.read(reservationScope(input.clientId)));
24
+ if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'))fail('WORKFLOW_PENDING','unfinished household workflow must be reconciled first');
25
+ const records=await store.read(scope(input.clientId));
26
+ if(!Array.isArray(records)||records.some(r=>r.version!==1||!object(r.steps)))fail('WORKFLOW_JOURNAL_INVALID','invalid workflow journal');
27
+ if(records.some(r=>r.stage!=='verified'&&!(r.kind==='correction'&&r.stage==='rejected')))fail('WORKFLOW_PENDING','unfinished workflow must be reconciled first');
28
+ const entries=records.filter(r=>r.key===input.enrollmentKey&&r.kind==='entry'&&r.stage==='verified');
29
+ if(entries.length!==1)fail('ROW_REGISTRATION_ENTRY_REQUIRED','verified enrollment workflow required');const entry=entries[0];
30
+ const prior=Object.hasOwn(entry.rowRegistrations??{},input.key)?entry.rowRegistrations[input.key]:undefined,intentHash=hash(input);
31
+ if(prior&&prior.intentHash!==intentHash)fail('ROW_REGISTRATION_INTENT_CONFLICT','registration key conflicts with previous intent');
32
+ const target={clientId:input.clientId,enrollmentId:entry.enrollmentId},at=entry.context.entryDate;
33
+ const verifyEnrollment=async()=>{
34
+ const s=await enrollmentSession.readEnrollment(target);
35
+ if(s?.source!=='gwt-rpc'||s.tenant!==api.tenant||s.clientId!==target.clientId||s.enrollmentId!==target.enrollmentId||s.providerId!==entry.context.providerId||s.enrollmentType!==entry.context.type||parseRestInstant(s.projectStartDate)!==parseRestInstant(at))fail('ROW_REGISTRATION_CONTEXT_MISMATCH','enrollment identity changed during row registration');
36
+ };
37
+ await verifyEnrollment();
38
+ const discovered=await discover({...entry.context}),definition=discovered.entry.definition;
39
+ const schemaHash=hash({providerId:discovered.provider.providerId,entry:{assessment:discovered.entry.assessment,questions:definition.questions},interim:{assessment:discovered.interim.assessment,questions:discovered.interim.definition.questions}});
40
+ if(schemaHash!==entry.schemaHash)fail('WORKFLOW_SCHEMA_CHANGED','program assessment configuration changed');
41
+ const rowMap=entry.rows??{},parents=entry.rowParents??{};
42
+ for(const row of input.rows){
43
+ if(!definition.questions.some(q=>q.fieldName===row.parentField&&q.questionType==='sub_assessment'))fail('ROW_REGISTRATION_PARENT_INVALID','subassessment parent not in entry definition');
44
+ if(Object.hasOwn(rowMap,row.key)&&(rowMap[row.key]!==row.recordsetId||!Object.hasOwn(parents,row.key))||Object.entries(rowMap).some(([key,id])=>key!==row.key&&id===row.recordsetId)||Object.hasOwn(parents,row.key)&&parents[row.key]!==row.parentField)fail('ROW_REGISTRATION_BINDING_CONFLICT','row key or recordset is already bound differently');
45
+ }
46
+ const verifyRows=async effectiveDate=>{
47
+ const response=toItems(await api.assessments.get({path:{clientId:input.clientId,assessmentCode:entry.assessmentCode},query:{dateEffective:effectiveDate}}));
48
+ for(const row of input.rows){
49
+ const parent=response.filter(p=>p?.virt_field_name===row.parentField&&p.question_type==='sub_assessment');
50
+ if(parent.length!==1||!Array.isArray(parent[0].sub_assessment_answer_list))fail('ROW_REGISTRATION_READ_UNAVAILABLE','complete row registration read required');
51
+ const matches=parent[0].sub_assessment_answer_list.filter(r=>r?.recordset_id===row.recordsetId);
52
+ if(matches.length!==1||!Array.isArray(matches[0].sub_assessment_list))fail('ROW_REGISTRATION_ROW_MISSING','selected row is missing or ambiguous at the entry date or current read');
53
+ }
54
+ };
55
+ await verifyRows(at);await verifyRows(new Date(Math.max(Date.now(),parseRestInstant(at)+1000)).toISOString());await verifyEnrollment();
56
+ entry.rows=Object.fromEntries([...Object.entries(rowMap),...input.rows.map(r=>[r.key,r.recordsetId])]);
57
+ entry.rowParents=Object.fromEntries([...Object.entries(parents),...input.rows.map(r=>[r.key,r.parentField])]);
58
+ entry.rowRegistrations=Object.fromEntries([...Object.entries(entry.rowRegistrations??{}),[input.key,{intentHash,rows:input.rows}]]);
59
+ await store.write(scope(input.clientId),structuredClone(records));
60
+ return {key:input.key,enrollmentKey:input.enrollmentKey,clientId:input.clientId,enrollmentId:entry.enrollmentId,outcome:prior?'already-present':'verified',recordsetIds:structuredClone(entry.rows)};
61
+ });
62
+ };
63
+ }
@@ -0,0 +1,49 @@
1
+ import {parseRestInstant,toItems} from '../dist/esm/index.js';
2
+
3
+ const positive=v=>Number.isSafeInteger(v)&&v>0;
4
+ const pairs=members=>JSON.stringify(members.map(({clientId,enrollmentId})=>({clientId,enrollmentId})).sort((a,b)=>a.clientId-b.clientId));
5
+
6
+ /** Resolves only a unique current household satisfying the adoption membership contract. */
7
+ export function createHouseholdEnrollmentDiscovery({api,enrollmentSession,makeError}){
8
+ const fail=(code,reason)=>{throw makeError(reason,{code,committed:false});};
9
+ return async input=>{
10
+ if(!input||Object.keys(input).some(k=>!['headClientId','headEnrollmentId'].includes(k))||!positive(input.headClientId)||!positive(input.headEnrollmentId))fail('HOUSEHOLD_DISCOVERY_INPUT_INVALID','head client and enrollment IDs required');
11
+ if(typeof enrollmentSession?.readEnrollment!=='function'||typeof api.households?.listForClient!=='function'||typeof api.households?.get!=='function')fail('HOUSEHOLD_DISCOVERY_READERS_REQUIRED','GWT detail and complete household readers required');
12
+ const target={clientId:input.headClientId,enrollmentId:input.headEnrollmentId};
13
+ const anchor=await enrollmentSession.readEnrollment(target);
14
+ const valid=(s,t)=>s?.source==='gwt-rpc'&&s.tenant===api.tenant&&s.clientId===t.clientId&&s.enrollmentId===t.enrollmentId&&positive(s.providerId)&&typeof s.enrollmentType==='string'&&Number.isFinite(parseRestInstant(s.projectStartDate));
15
+ if(!valid(anchor,target)||!Array.isArray(anchor.members)||!anchor.members.length||anchor.members.some(m=>!positive(m?.clientId)||!positive(m?.enrollmentId))||new Set(anchor.members.map(m=>m.clientId)).size!==anchor.members.length||new Set(anchor.members.map(m=>m.enrollmentId)).size!==anchor.members.length||!anchor.members.some(m=>m.clientId===target.clientId&&m.enrollmentId===target.enrollmentId))fail('HOUSEHOLD_DISCOVERY_DETAIL_INVALID','complete linked enrollment cohort required');
16
+ if(anchor.members.length===1&&anchor.grouped!==true)fail('ENROLLMENT_NOT_GROUPED','standalone enrollment uses adoptEnrollment');
17
+ if(anchor.grouped===false&&anchor.members.length>1)fail('HOUSEHOLD_DISCOVERY_DETAIL_INVALID','group flag conflicts with linked cohort');
18
+ const members=anchor.members.map(({clientId,enrollmentId})=>({clientId,enrollmentId})).sort((a,b)=>a.clientId-b.clientId),starts=new Map();
19
+ for(const member of members){
20
+ const s=member.clientId===target.clientId?anchor:await enrollmentSession.readEnrollment(member);
21
+ if(!valid(s,member)||s.providerId!==anchor.providerId||s.enrollmentType!==anchor.enrollmentType||!Array.isArray(s.members)||pairs(s.members)!==pairs(members))fail('HOUSEHOLD_DISCOVERY_COHORT_CHANGED','linked enrollment cohort changed during discovery');
22
+ starts.set(member.clientId,parseRestInstant(s.projectStartDate));
23
+ }
24
+ const candidates=[],seen=new Set();let total;
25
+ for(let startPage=1;startPage<=100;startPage++){
26
+ const result=await api.households.listForClient({path:{clientId:input.headClientId},query:{perPage:100,startPage,total_count:true}});
27
+ if(!Number.isSafeInteger(result?.count)||result.count<0||!Array.isArray(result.values)||total!==undefined&&total!==result.count)fail('HOUSEHOLD_DISCOVERY_LIST_INCOMPLETE','household list count unavailable or changed');total=result.count;
28
+ for(const h of result.values){
29
+ if(!positive(h?.householdId)||seen.has(h.householdId)||!Array.isArray(h.clients)||h.clients.some(m=>!positive(m?.clientId))||new Set(h.clients.map(m=>m.clientId)).size!==h.clients.length||!h.clients.some(m=>m.clientId===input.headClientId))fail('HOUSEHOLD_DISCOVERY_LIST_INCOMPLETE','invalid household list');
30
+ seen.add(h.householdId);candidates.push(h);
31
+ }
32
+ if(seen.size===total)break;
33
+ if(seen.size>total||!result.values.length||startPage===100)fail('HOUSEHOLD_DISCOVERY_LIST_INCOMPLETE','incomplete household list');
34
+ }
35
+ const matches=h=>{
36
+ if(h.isActive!==true||!Array.isArray(h.clients))return false;
37
+ const heads=h.clients.filter(m=>m.headofhouseholdStatus&&m.isActive!==false&&!m.dateLeft);
38
+ return heads.length===1&&heads[0].clientId===input.headClientId&&members.every(m=>{
39
+ const found=h.clients.filter(c=>c.clientId===m.clientId);
40
+ return found.length===1&&found[0].isActive!==false&&!found[0].dateLeft&&Number.isFinite(parseRestInstant(found[0].dateJoined))&&parseRestInstant(found[0].dateJoined)<=starts.get(m.clientId);
41
+ });
42
+ };
43
+ const found=candidates.filter(matches);
44
+ if(found.length!==1)fail(found.length?'HOUSEHOLD_DISCOVERY_AMBIGUOUS':'HOUSEHOLD_DISCOVERY_NOT_FOUND','a unique current household matching the full cohort, head and dates is required');
45
+ const fresh=toItems(await api.households.get({path:{householdId:found[0].householdId}}));
46
+ if(fresh.length!==1||fresh[0].householdId!==found[0].householdId||!matches(fresh[0]))fail('HOUSEHOLD_DISCOVERY_COHORT_CHANGED','household membership changed during discovery');
47
+ return {headClientId:input.headClientId,headEnrollmentId:input.headEnrollmentId,householdId:fresh[0].householdId,householdSource:'verified-membership',providerId:anchor.providerId,type:anchor.enrollmentType,members};
48
+ };
49
+ }
@@ -17,10 +17,12 @@ export class LoginError extends Error {
17
17
  }
18
18
  export interface UiSession {readonly connection:UiConnection;browser: Browser;context: BrowserContext;page: Page;passwordRecovery:'restored-original'|'not-needed';ensureAuthenticated():Promise<void>;reauthenticate():Promise<void>;close():Promise<void>;}
19
19
  export interface PlaywrightGwtOptions {page:Page;tenant:string;moduleBaseUrl:string;}
20
- export interface PlaywrightEnrollmentSnapshot extends GwtEnrollmentSnapshot {providerId:number;enrollmentType:string;exits:readonly GwtEnrollmentExit[];}
20
+ export interface PlaywrightEnrollmentSnapshot extends GwtEnrollmentSnapshot {providerId:number;enrollmentType:string;exits:readonly GwtEnrollmentExit[];groupId?:number|string|null;grouped?:boolean;}
21
21
  export interface EnrollmentListRow {
22
22
  enrollmentId:number;clientId:number;enrollmentProvider:number;type:string;
23
23
  projectStartDate:string;projectExitDate:string|null;isActive:boolean;
24
+ /** An enrollment group pointer, not a household identifier or member count. */
25
+ groupId:number|string|null;grouped:boolean;
24
26
  }
25
27
  export interface EnrollmentListSnapshot {
26
28
  source:'gwt-rpc';tenant:string;clientId:number;count:number;values:EnrollmentListRow[];