@choiceapps/communityservices-sdk 0.6.0 → 0.8.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 (61) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/browser/enrollment-workflow.d.mts +29 -5
  3. package/browser/enrollment-workflow.mjs +88 -34
  4. package/browser/entry-correction.mjs +13 -11
  5. package/browser/entry-row-registration.mjs +63 -0
  6. package/browser/household-enrollment-discovery.mjs +49 -0
  7. package/browser/index.d.mts +7 -2
  8. package/browser/index.mjs +1 -1
  9. package/browser/playwright-session.mjs +24 -8
  10. package/browser/workflow-error-codes.mjs +198 -0
  11. package/dist/cjs/mobile-preflight.d.ts +19 -0
  12. package/dist/cjs/mobile-preflight.d.ts.map +1 -0
  13. package/dist/cjs/mobile-preflight.js +75 -0
  14. package/dist/cjs/mobile-preflight.js.map +1 -0
  15. package/dist/cjs/picklist-labels.d.ts +22 -0
  16. package/dist/cjs/picklist-labels.d.ts.map +1 -0
  17. package/dist/cjs/picklist-labels.js +35 -0
  18. package/dist/cjs/picklist-labels.js.map +1 -0
  19. package/dist/cjs/v2.d.ts +4 -0
  20. package/dist/cjs/v2.d.ts.map +1 -1
  21. package/dist/cjs/v2.js +5 -0
  22. package/dist/cjs/v2.js.map +1 -1
  23. package/dist/cjs/v3.d.ts +4 -0
  24. package/dist/cjs/v3.d.ts.map +1 -1
  25. package/dist/cjs/v3.js +5 -0
  26. package/dist/cjs/v3.js.map +1 -1
  27. package/dist/cjs/write-labels.d.ts +13 -0
  28. package/dist/cjs/write-labels.d.ts.map +1 -0
  29. package/dist/cjs/write-labels.js +68 -0
  30. package/dist/cjs/write-labels.js.map +1 -0
  31. package/dist/esm/mobile-preflight.d.ts +19 -0
  32. package/dist/esm/mobile-preflight.d.ts.map +1 -0
  33. package/dist/esm/mobile-preflight.js +70 -0
  34. package/dist/esm/mobile-preflight.js.map +1 -0
  35. package/dist/esm/picklist-labels.d.ts +22 -0
  36. package/dist/esm/picklist-labels.d.ts.map +1 -0
  37. package/dist/esm/picklist-labels.js +32 -0
  38. package/dist/esm/picklist-labels.js.map +1 -0
  39. package/dist/esm/v2.d.ts +4 -0
  40. package/dist/esm/v2.d.ts.map +1 -1
  41. package/dist/esm/v2.js +3 -0
  42. package/dist/esm/v2.js.map +1 -1
  43. package/dist/esm/v3.d.ts +4 -0
  44. package/dist/esm/v3.d.ts.map +1 -1
  45. package/dist/esm/v3.js +3 -0
  46. package/dist/esm/v3.js.map +1 -1
  47. package/dist/esm/write-labels.d.ts +13 -0
  48. package/dist/esm/write-labels.d.ts.map +1 -0
  49. package/dist/esm/write-labels.js +65 -0
  50. package/dist/esm/write-labels.js.map +1 -0
  51. package/docs/assessments.md +50 -1
  52. package/docs/capabilities.md +19 -0
  53. package/docs/enrollments.md +41 -5
  54. package/docs/reference.md +3 -2
  55. package/docs/troubleshooting.md +17 -0
  56. package/docs/vendor-issues/lookup-write-labels.md +13 -0
  57. package/examples/README.md +3 -0
  58. package/examples/catalog-write-labels.mts +15 -0
  59. package/examples/checked-assessment-writes.mts +14 -0
  60. package/examples/enrollment-discovery-and-correction.mts +19 -0
  61. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 - 2026-11-11
4
+
5
+ - Resolve full lookup write labels without existing answers using `enrollmentSession.readPicklistOptions` and `createCatalogWriteLabelResolver`.
6
+ - Discover labels automatically in enrollment workflows when the configured session supports catalog reads and no explicit `writeLabel` override is supplied, including corrections and mandatory carry-forward.
7
+ - Match option ID, picklist ID and code; preserve full names including HUD suffixes, and reject incomplete or mismatched catalogs before writes. Read explicit option IDs in bounded batches.
8
+ - Read-only training verification matched 1,288 distinct catalog options to Coordinated Entry metadata from three synthetic clients, including the shared enhanced yes/no list and living-situation options. Live profile dropdowns matched the DOB-quality catalog labels. No new assessment writes were performed.
9
+
10
+ ## 0.7.0 - 2026-11-10
11
+
12
+ - Add `createReadBackWriteLabelResolver` for exact observed lookup labels, including HUD suffixes. Unobserved options remain unresolved; no suffix is inferred from metadata.
13
+ - Expose `groupId` and `grouped` on GWT enrollment catalog rows and details without per-row reads.
14
+ - Add `discoverHouseholdEnrollment` and adoption from a head's client/enrollment IDs, requiring a unique verified household membership match.
15
+ - Add `registerEntryRows` to bind existing recordsets to adopted entry keys for in-place corrections.
16
+ - Add stable workflow error codes and per-operation `committed` markers, preserving uncertain prior writes and original causes.
17
+ - Add `writeMobileAnswersChecked` and enrollment-workflow scalar collision preflight for questions shared across instruments.
18
+ - 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.
19
+
3
20
  ## 0.6.0 - 2026-11-10
4
21
 
5
22
  - 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.
@@ -1,5 +1,5 @@
1
1
  import type {DateOfBirth} from './date-of-birth.mjs';
2
- import type {Answers, AssessmentDefinition, EncodingOptions, GwtEnrollmentHelpers, Provider, WellSkyClient} from '../dist/esm/index.js';
2
+ import type {PicklistCatalogSnapshot, Answers, AssessmentDefinition, EncodingOptions, GwtEnrollmentHelpers, Provider, WellSkyClient} from '../dist/esm/index.js';
3
3
  import type {UiMutationStore,EnrollmentReader} from './index.mjs';
4
4
  import type {ProviderAssessmentItem, ProgramDiscovery} from './program-discovery.mjs';
5
5
  import type {HouseholdExitUi,HouseholdExitDetailInput} from './household-exit.mjs';
@@ -41,6 +41,8 @@ export interface EnrollmentDiscoveryInput {
41
41
  entryDate:string;
42
42
  }
43
43
  export interface EnrollmentWorkflowDiscovery {
44
+ /** Current exact label catalog when automatic browser discovery is available. */
45
+ picklistOptions?:PicklistCatalogSnapshot;
44
46
  provider:Provider;
45
47
  entry:{assessment:ProviderAssessmentItem;definition:AssessmentDefinition};
46
48
  interim:{assessment:ProviderAssessmentItem;definition:AssessmentDefinition};
@@ -107,6 +109,19 @@ export interface AdoptHouseholdEnrollmentInput {
107
109
  /** Complete linked cohort, including already exited participants. IDs are explicit, not inferred. */
108
110
  members:Array<{clientId:number;enrollmentId:number}>;
109
111
  }
112
+ export interface HouseholdEnrollmentDiscoveryInput {headClientId:number;headEnrollmentId:number;}
113
+ export interface HouseholdEnrollmentDiscoveryResult extends HouseholdEnrollmentDiscoveryInput {
114
+ householdId:number;householdSource:'verified-membership';providerId:number;type:string;
115
+ members:Array<{clientId:number;enrollmentId:number}>;
116
+ }
117
+ export interface AdoptHouseholdEnrollmentFromHeadInput extends HouseholdEnrollmentDiscoveryInput {key:string;}
118
+ export interface RegisterEntryRowsInput {
119
+ key:string;enrollmentKey:string;clientId:number;
120
+ rows:Array<{key:string;parentField:string;recordsetId:number}>;
121
+ }
122
+ export interface RegisterEntryRowsResult {
123
+ key:string;enrollmentKey:string;clientId:number;enrollmentId:number;outcome:'verified'|'already-present';recordsetIds:Record<string,number>;
124
+ }
110
125
  export interface EnrollmentWorkflowResult {
111
126
  key:string;
112
127
  outcome:'verified'|'already-present';
@@ -146,12 +161,16 @@ export interface EnrollmentWorkflow {
146
161
  exitEnrollment(input:ExitEnrollmentInput):Promise<ExitEnrollmentResult>;
147
162
  /** Patch an open entry at its original timestamp, before any review or exit. */
148
163
  correctEntry(input:EntryCorrectionInput):Promise<EntryCorrectionResult>;
164
+ /** Bind explicit existing row IDs to stable keys without writing HMIS records. */
165
+ registerEntryRows(input:RegisterEntryRowsInput):Promise<RegisterEntryRowsResult>;
166
+ /** Resolve a unique current household satisfying full-cohort membership/head/date checks. */
167
+ discoverHouseholdEnrollment(input:HouseholdEnrollmentDiscoveryInput):Promise<HouseholdEnrollmentDiscoveryResult>;
149
168
  /** Verify unchanged state and independently confirmed rejection; keeps the attempt history. */
150
169
  resolveEntryCorrection(input:ResolveEntryCorrectionInput):Promise<ResolveEntryCorrectionResult>;
151
170
  /** Verify an existing standalone entry and journal it without an HMIS write. */
152
171
  adoptEnrollment(input:AdoptEnrollmentInput):Promise<EnrollmentWorkflowResult>;
153
172
  /** Verify all existing linked entries, original head and dates; no HMIS writes. */
154
- adoptHouseholdEnrollment(input:AdoptHouseholdEnrollmentInput):Promise<HouseholdEnrollmentResult>;
173
+ adoptHouseholdEnrollment(input:AdoptHouseholdEnrollmentInput|AdoptHouseholdEnrollmentFromHeadInput):Promise<HouseholdEnrollmentResult>;
155
174
  /** Reads the local journal only. A verified journal is not fresh server verification. */
156
175
  getStatus(input:{clientId:number;key:string}):Promise<EnrollmentWorkflowStatus|null>;
157
176
  createHouseholdEnrollment(input:HouseholdEnrollmentInput):Promise<HouseholdEnrollmentResult>;
@@ -237,17 +256,22 @@ export interface EnrollmentWorkflowOptions {
237
256
  * `standard` writes them through `assessments.create` with every mandatory answer, after the mobile scalars;
238
257
  * every mandatory question must be a lookup. An unfinished journal never reconciles in the other mode. */
239
258
  yesNoWrites?:'mobile'|'standard';
240
- /** Required for `yesNoWrites: 'standard'`: tenant-verified labels for mandatory lookup answers. */
259
+ /** Explicit tenant-verified override. When omitted, a configured readPicklistOptions reader supplies exact catalog labels automatically. */
241
260
  writeLabel?:EncodingOptions['writeLabel'];
242
261
  }
243
262
  export class EnrollmentWorkflowError extends Error {
244
263
  readonly reason:string;
245
- readonly code?:string;
264
+ readonly code:string;
265
+ readonly committed:false|'unknown';
266
+ readonly operation?:string;
267
+ /** Resolution-call failure does not establish the original correction's outcome. */
268
+ readonly originalCommitted?:'unknown';
269
+ readonly reauthenticated?:boolean;
246
270
  readonly fields?:string[];
247
271
  readonly status?:number;
248
272
  /** Failed durable stage, when known. The original write error is available as cause in memory. */
249
273
  readonly step?:string;
250
- constructor(reason:string,options?:ErrorOptions&{step?:string;code?:string;fields?:string[];status?:number});
274
+ constructor(reason:string,options?:ErrorOptions&{step?:string;code?:string;fields?:string[];status?:number;committed?:false|'unknown'});
251
275
  }
252
276
  /** An unavailable list is never evidence that no enrollment exists. */
253
277
  export class EnrollmentReadError extends EnrollmentWorkflowError {
@@ -2,20 +2,24 @@ 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
- import {readProgramCatalog,encodeAnswers,encodeNewSubassessmentRow,toItems,parseRestInstant} from '../dist/esm/index.js';
9
+ import {readProgramCatalog,flattenQuestions,createCatalogWriteLabelResolver,encodeAnswers,encodeNewSubassessmentRow,toItems,parseRestInstant} from '../dist/esm/index.js';
7
10
 
8
11
  const object=v=>v!==null&&typeof v==='object'&&!Array.isArray(v);
9
12
  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,9 +115,20 @@ 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
- 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');
131
+ if(!['mobile','standard'].includes(yesNoWrites)||yesNoWrites==='standard'&&(typeof writeLabel!=='function'&&typeof enrollmentSession?.readPicklistOptions!=='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');
118
133
  for(const [type,locations]of Object.entries(assessmentLocations)){
119
134
  if(!entryExitTypes.includes(type))fail('unsupported Entry/Exit type in assessment locations');
@@ -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');
@@ -134,12 +149,22 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
134
149
  if(definition.tenant!==api.tenant||definition.assessmentCode!==assessment.code||definition.unresolvedPicklists.length)fail('assessment metadata incomplete');
135
150
  resolved[phase]={assessment,definition};
136
151
  }
137
- return {provider:providers[0],...resolved};
152
+ let picklistOptions;
153
+ if(writeLabel===undefined&&typeof enrollmentSession?.readPicklistOptions==='function'){
154
+ const optionIds=[...new Set(Object.values(resolved).flatMap(({definition})=>flattenQuestions(definition.questions).filter(({question})=>question.questionType==='lookup').flatMap(({question})=>question.options.map(o=>o.id))))];
155
+ picklistOptions=await enrollmentSession.readPicklistOptions({clientId:input.clientId,optionIds});
156
+ if(picklistOptions?.clientId!==input.clientId)fail('picklist catalog client mismatch',{code:'PICKLIST_CATALOG_CONTEXT_MISMATCH'});
157
+ for(const {definition}of Object.values(resolved))createCatalogWriteLabelResolver(definition,picklistOptions);
158
+ }
159
+ return {provider:providers[0],...resolved,...(picklistOptions?{picklistOptions}:{})};
138
160
  };
139
161
  const scope=clientId=>JSON.stringify([tenantScope,'enrollment-workflow',clientId]);
140
162
  const reservationScope=clientId=>JSON.stringify([tenantScope,'household-workflow-reservation',clientId]);
141
163
  const {correctEntry,resolveEntryCorrection}=createEntryCorrector({api,store,enrollmentSession,writeLabel,discover,scope,reservationScope,makeError:(reason,options)=>new EnrollmentWorkflowError(reason,options)});
164
+ const registerEntryRows=createEntryRowRegistrar({api,store,enrollmentSession,discover,scope,reservationScope,makeError:(reason,options)=>new EnrollmentWorkflowError(reason,options)});
165
+ const discoverHouseholdEnrollment=createHouseholdEnrollmentDiscovery({api,enrollmentSession,makeError:(reason,options)=>new EnrollmentWorkflowError(reason,options)});
142
166
  const run=async(kind,raw,internal={})=>{
167
+ const operation=internal.operation??{attempted:false};
143
168
  const standaloneExit=kind==='exit'&&internal.standaloneExit===true;
144
169
  fields(raw,kind==='entry'?['key','clientId','providerId','type','entryDate','assessment']:['key','enrollmentKey','clientId',kind==='exit'?'exitDate':'reviewDate','assessment',...(standaloneExit?['destination','reasonForLeaving','destinationDetails','reasonForLeavingOther']:[])]);
145
170
  const input=structuredClone(raw);key(input.key);if(!positive(input.clientId))fail('client ID required');
@@ -153,7 +178,9 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
153
178
  return locked(scope(input.clientId),async()=>{
154
179
  const reservations=await store.withLock(reservationScope(input.clientId),()=>store.read(reservationScope(input.clientId)));
155
180
  if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'&&r.owner!==internal.owner))fail('unfinished household workflow must be reconciled first');
181
+ const inheritedAttempt=operation.attempted;operation.attempted=true;
156
182
  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');
183
+ operation.attempted=inheritedAttempt||records.some(r=>r.key===input.key&&Object.keys(r.steps).length>0);
157
184
  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
185
  const matches=records.filter(r=>r.key===input.key);if(matches.length>1)fail('duplicate journal keys');let record=matches[0];
159
186
  const intentHash=hash({kind,input,...(internal.owner?{household:internal.owner}:{})});if(record&&record.intentHash!==intentHash)fail('key conflicts with previous intent');
@@ -190,7 +217,8 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
190
217
  const definition=discovered[kind].definition,code=definition.assessmentCode,at=kind==='entry'?input.entryDate:(input.exitDate??input.reviewDate);
191
218
  const exitSchemaHash=kind==='exit'?hash({assessment:discovered.exit.assessment,questions:definition.questions}):undefined;
192
219
  if(record&&kind==='exit'&&record.exitSchemaHash!==exitSchemaHash)fail('exit assessment configuration changed');
193
- const plan=compile(definition,input.assessment,{yesNoWrites,writeLabel});
220
+ const operationWriteLabel=writeLabel??(discovered.picklistOptions?createCatalogWriteLabelResolver(definition,discovered.picklistOptions):undefined);
221
+ const plan=compile(definition,input.assessment,{yesNoWrites,writeLabel:operationWriteLabel});
194
222
  const carriesBirthDate=plan.carry.includes('SVPPROFDOB'),hasBirthDate=plan.dateOfBirth!==undefined||carriesBirthDate;
195
223
  let carriedBirthDate;
196
224
  if(hasBirthDate){
@@ -226,6 +254,17 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
226
254
  // that instant; one second later returns them. Only the yes/no step needs this.
227
255
  const readSaved=async()=>questions(await api.assessments.get({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:new Date(Date.parse(at)+1000).toISOString()}}));
228
256
  const initial=await read();
257
+ if(!record&&plan.scalar.length){
258
+ const saved=await readSaved();
259
+ const existing=plan.scalar.map(q=>({q,row:saved.find(r=>r.virt_field_name===q.virt_field_name)}));
260
+ const complete=existing.every(({q,row})=>row&&sameAnswer(q,row.answer_value)&&restDate(row.date_effective)===restDate(at));
261
+ if(!complete){
262
+ 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);
263
+ if(unavailable.length)fail('mobile scalar preflight unavailable',{code:'MOBILE_PREFLIGHT_UNAVAILABLE',fields:unavailable});
264
+ const collisions=existing.filter(({row})=>restDate(row.date_effective)===restDate(at)).map(({q})=>q.virt_field_name);
265
+ if(collisions.length)fail('mobile scalar answer already exists at the effective date',{code:'MOBILE_ANSWER_EXISTS',fields:collisions});
266
+ }
267
+ }
229
268
  if(expectedHead===input.clientId&&headQuestion)checkHeadAnswer(proposedHead?.answer_value??initial.find(q=>q.virt_field_name==='HUD_RELATIONTOHOH')?.answer_value);
230
269
  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
270
  // Resend mandatory answers with the standard yes/no write. A blank write must not hide a recorded value.
@@ -235,12 +274,12 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
235
274
  const recorded=initial.find(q=>q.virt_field_name===m.virt_field_name)?.answer_value;
236
275
  if(m.carry){
237
276
  const question=definition.questions.find(q=>q.fieldName===m.virt_field_name),options=question.options.filter(o=>String(o.id)===String(recorded));
238
- 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`);
277
+ const label=options.length===1?operationWriteLabel?.(question,options[0]):undefined;
278
+ 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
279
  if(m.virt_field_name==='HUD_RELATIONTOHOH')checkHeadAnswer(recorded);
241
280
  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
281
  }
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`);
282
+ 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
283
  return m;
245
284
  });
246
285
  // Validate closure targets before creating enrollment/review or submitting any answers.
@@ -274,6 +313,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
274
313
  if(previous)throw new EnrollmentWorkflowError(previous.state==='verified'?'verified step changed':'uncertain write requires reconciliation',{step:name});
275
314
  record.steps[name]={state:'attempted'};await persist();
276
315
  let writeError;
316
+ operation.attempted=true;
277
317
  try{await submit(result);}catch(error){writeError=error;}
278
318
  // A write response alone never establishes success or absence. Preserve its
279
319
  // diagnostic cause in memory while reconciliation remains authoritative.
@@ -299,6 +339,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
299
339
  else {const found=await findEnrollment();if(!found.found)fail('saved enrollment missing');record.enrollmentId=entry.enrollmentId;}
300
340
  await persist();
301
341
  if(kind==='interim'){
342
+ if(!internal.readReview)operation.attempted=true;
302
343
  // The GWT helper has its own journal and authoritative reads; it owns uncertain-write reconciliation.
303
344
  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
345
  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 +402,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
361
402
  const known=record.steps.birthDate.value;
362
403
  result=await birthDates.verify({...dobContext,dateOfBirth:known.dateOfBirth,effectiveDate:known.effectiveDate,allowLater:true});
363
404
  }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}}:{})});
405
+ 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
406
  }else result=await birthDates.verify({...dobContext,dateOfBirth:carriedBirthDate.dateOfBirth,effectiveDate:carriedBirthDate.effectiveDate,allowLater:true});
366
407
  record.steps.birthDate={state:'verified',value:{clientId:input.clientId,dateOfBirth:result.dateOfBirth,effectiveDate:result.effectiveDate}};await persist();
367
408
  }
@@ -373,6 +414,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
373
414
  const memberKey=(key,clientId)=>'household:'+hash({key,clientId});
374
415
  const withClients=(ids,work,index=0)=>index===ids.length?work():store.withLock(scope(ids[index]),()=>withClients(ids,work,index+1));
375
416
  const adopt=async(household,raw)=>{
417
+ if(household&&raw&&Object.hasOwn(raw,'headEnrollmentId')){
418
+ fields(raw,['key','headClientId','headEnrollmentId']);key(raw.key);
419
+ const found=await discoverHouseholdEnrollment({headClientId:raw.headClientId,headEnrollmentId:raw.headEnrollmentId});
420
+ raw={key:raw.key,headClientId:found.headClientId,householdId:found.householdId,members:found.members};
421
+ }
376
422
  fields(raw,household?['key','householdId','headClientId','members']:['key','clientId','enrollmentId']);
377
423
  const input=structuredClone(raw);key(input.key);
378
424
  if(typeof enrollmentSession?.readEnrollment!=='function')fail('authoritative enrollment session required for adoption');
@@ -448,7 +494,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
448
494
  });
449
495
  });
450
496
  };
451
- const householdRun=async(kind,raw)=>{
497
+ const householdRun=async(kind,raw,operation={attempted:false})=>{
452
498
  if(typeof enrollmentSession?.readEnrollment!=='function'||typeof helpers.addHouseholdMembers!=='function'||typeof api.households?.get!=='function')fail('household API and authoritative enrollment session required');
453
499
  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
500
  const input=structuredClone(raw);key(input.key);
@@ -463,8 +509,10 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
463
509
  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
510
  // Read the cohort under its journal lock, then acquire all client locks in order.
465
511
  // Recheck under both locks so concurrent membership growth cannot escape protection.
512
+ operation.attempted=true;
466
513
  const prior=await store.withLock(householdScope(input.headClientId),()=>store.read(householdScope(input.headClientId)));
467
514
  if(!Array.isArray(prior))fail('invalid household journal');
515
+ operation.attempted=prior.some(r=>r.key===input.key);
468
516
  const lockedIds=[...new Set([input.headClientId,...ids,...prior.flatMap(r=>r.clientIds??[])])].sort((a,b)=>a-b);
469
517
  return withClients(lockedIds,()=>store.withLock(householdScope(input.headClientId),async()=>{
470
518
  const records=await store.read(householdScope(input.headClientId));
@@ -532,7 +580,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
532
580
  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
581
  // Validate every member before any record mutation. The same client locks cover
534
582
  // 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}:{})}));
583
+ 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
584
  if(kind==='addition'&&record?.entrySchemas&&canonical(record.entrySchemas)!==canonical(preflight))fail('additional member assessment configuration changed');
537
585
  if(kind==='exit'&&record?.exitSchemas&&canonical(record.exitSchemas)!==canonical(preflight))fail('exit assessment configuration changed');
538
586
  const wasVerified=record?.stage==='verified';
@@ -580,14 +628,14 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
580
628
  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
629
  }
582
630
  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();
631
+ 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
632
  // Reject unexpected members before the attachment helper is allowed to write.
585
633
  await readTarget({clientId:head.clientId,enrollmentId:result.enrollmentId},{partial:!record.attached});
586
634
  record.attachmentAttempted=true;await persist();
587
- const attached=await helpers.addHouseholdMembers({clientId:head.clientId,enrollmentId:result.enrollmentId,memberClientIds:ids.filter(id=>id!==head.clientId)});
635
+ operation.attempted=true;const attached=await helpers.addHouseholdMembers({clientId:head.clientId,enrollmentId:result.enrollmentId,memberClientIds:ids.filter(id=>id!==head.clientId)});
588
636
  const snapshot=validateSnapshot(attached.snapshot,{clientId:head.clientId,enrollmentId:result.enrollmentId});
589
637
  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();}
638
+ 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
639
  }else if(kind==='addition'){
592
640
  if(!record.anchor){
593
641
  for(const clientId of Object.keys(catalog).map(Number).sort((a,b)=>a-b)){
@@ -601,7 +649,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
601
649
  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
650
  }
603
651
  record.attachmentAttempted=true;await persist();
604
- const attached=await helpers.addHouseholdMembers({...record.anchor,memberClientIds:ids});
652
+ operation.attempted=true;const attached=await helpers.addHouseholdMembers({...record.anchor,memberClientIds:ids});
605
653
  validateSnapshot(attached.snapshot,record.anchor);
606
654
  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
655
  record.attached=true;await persist();await preserve();
@@ -619,13 +667,13 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
619
667
  const change={...target,providerId:context.providerId,type:context.type,entryDate:input.entryDate,cohort};
620
668
  await memberEntryDatePort.validateMemberEntryDate(change);
621
669
  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. */}
670
+ operation.attempted=true;try{await memberEntryDatePort.saveMemberEntryDate(change);}catch{/* Resolve only through readback; never replay the date update. */}
623
671
  }
624
672
  if(record.entryDates[clientId]==='attempted'&&record.entryDateInputs[clientId])await memberEntryDatePort.validateMemberEntryDate(record.entryDateInputs[clientId]);
625
673
  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
674
  await preserve();record.entryDates[clientId]='verified';await persist();
627
675
  }
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();}
676
+ 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
677
  }else if(kind==='interim'){
630
678
  // Verify all linked enrollments before making the single household review call.
631
679
  const existingReviews=[];
@@ -640,7 +688,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
640
688
  if(existingReviews.some(Boolean)&&existingReviews.some(r=>!r))fail('partial household review requires reconciliation');
641
689
  if(existingReviews[0]&&new Set(existingReviews.map(r=>r.reviewId)).size!==ids.length)fail('duplicate household review IDs');
642
690
  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);
691
+ operation.attempted=true;const review=await helpers.createInterimUpdate({...target,clientIds:ids,reviewDate:input.reviewDate});validateSnapshot(review.snapshot,target);
644
692
  const readReview=async target=>{
645
693
  const snapshot=await readTarget(target),reviews=snapshot.reviews.filter(r=>r.type==='Update'&&restDate(r.reviewDate)===restDate(input.reviewDate));
646
694
  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 +698,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
650
698
  // Pin every member's distinct review ID before writing any member's answers.
651
699
  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
700
  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();}
701
+ 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
702
  }else if(kind==='exit'){
655
703
  const target={clientId:ids[0],enrollmentId:catalog[ids[0]].enrollmentId};
656
704
  const normalizeLabel=value=>typeof value==='string'?value.replace(/\s*\(HUD\)\s*$/,'').trim():value;
@@ -681,7 +729,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
681
729
  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
730
  await exitUi.validateExit(exitInput);
683
731
  record.exitStep='attempted';await persist();
684
- try{await exitUi.saveExit(exitInput);}catch{/* Only authoritative readback establishes success. Never replay an uncertain save. */}
732
+ operation.attempted=true;try{await exitUi.saveExit(exitInput);}catch{/* Only authoritative readback establishes success. Never replay an uncertain save. */}
685
733
  observed=await inspect();if(!observed.found)fail('uncertain household exit requires reconciliation');
686
734
  }
687
735
  await validateHeadCoverage();await preserve();
@@ -689,7 +737,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
689
737
  const readExit=async target=>{
690
738
  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
739
  };
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();}
740
+ 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
741
  }
694
742
  if(kind==='exit'||kind==='addition')await preserve();
695
743
  await validateHeadCoverage();
@@ -698,13 +746,16 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
698
746
  }));
699
747
  };
700
748
  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),
749
+ correctEntry:input=>execute('correctEntry',operation=>correctEntry(input,operation)),
750
+ registerEntryRows:input=>execute('registerEntryRows',()=>registerEntryRows(input)),
751
+ discoverHouseholdEnrollment:input=>execute('discoverHouseholdEnrollment',()=>discoverHouseholdEnrollment(input)),
752
+ resolveEntryCorrection:input=>execute('resolveEntryCorrection',()=>resolveEntryCorrection(input)),
753
+ adoptEnrollment:input=>execute('adoptEnrollment',()=>adopt(false,input)),adoptHouseholdEnrollment:input=>execute('adoptHouseholdEnrollment',()=>adopt(true,input)),
754
+ addHouseholdMembers:input=>execute('addHouseholdMembers',operation=>householdRun('addition',input,operation)),
755
+ 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)),
756
+ discoverExit:input=>execute('discoverExit',()=>discover(input,true)),
707
757
  async getHouseholdStatus(input){
758
+ return execute('getHouseholdStatus',async()=>{
708
759
  fields(input,['headClientId','key']);if(!positive(input.headClientId))fail('head client ID required');key(input.key);
709
760
  return store.withLock(householdScope(input.headClientId),async()=>{
710
761
  const records=await store.read(householdScope(input.headClientId));if(!Array.isArray(records))fail('invalid household journal');
@@ -712,9 +763,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
712
763
  if(record.version!==1||!object(record.members))fail('invalid household journal');
713
764
  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
765
  });
766
+ });
715
767
  },
716
- discover,createEnrollment:input=>run('entry',input),createInterim:input=>run('interim',input),exitEnrollment:input=>run('exit',input,{standaloneExit:true}),
768
+ 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
769
  async getStatus(input){
770
+ return execute('getStatus',async()=>{
718
771
  fields(input,['clientId','key']);if(!positive(input.clientId))fail('client ID required');key(input.key);
719
772
  return store.withLock(scope(input.clientId),async()=>{
720
773
  const records=await store.read(scope(input.clientId));if(!Array.isArray(records))fail('invalid workflow journal');
@@ -722,6 +775,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
722
775
  if(r.version!==1||!object(r.steps))fail('invalid workflow journal');
723
776
  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
777
  });
778
+ });
725
779
  },
726
780
  };
727
781
  }
@@ -1,5 +1,5 @@
1
1
  import {createHash} from 'node:crypto';
2
- import {encodeAnswers,parseRestInstant,toItems} from '../dist/esm/index.js';
2
+ import {createCatalogWriteLabelResolver,encodeAnswers,parseRestInstant,toItems} from '../dist/esm/index.js';
3
3
 
4
4
  const object=v=>v!==null&&typeof v==='object'&&!Array.isArray(v);
5
5
  const canonical=v=>JSON.stringify(v,(_,x)=>object(x)?Object.fromEntries(Object.keys(x).sort().map(k=>[k,x[k]])):x);
@@ -24,7 +24,7 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
24
24
  if(type==='int'||type==='money'){const n=Number(v);if(!Number.isFinite(n))fail('invalid stored correction number');return n;}
25
25
  return String(v);
26
26
  };
27
- const encode=(questions,answers,clear,endpoint)=>{
27
+ const encode=(questions,answers,clear,endpoint,writeLabel)=>{
28
28
  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');
29
29
  const index=new Map(questions.map(q=>[q.fieldName,q]));
30
30
  const unsupportedClears=clear.filter(field=>index.get(field)?.questionType==='yes_no');
@@ -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];
@@ -92,7 +94,7 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
92
94
  const discovered=await discover({clientId:input.clientId,providerId,type,entryDate:at});
93
95
  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}});
94
96
  if(schemaHash!==entry.schemaHash||record&&schemaHash!==record.schemaHash)fail('program assessment configuration changed');
95
- const definition=discovered.entry.definition,code=definition.assessmentCode;
97
+ const definition=discovered.entry.definition,operationWriteLabel=writeLabel??(discovered.picklistOptions?createCatalogWriteLabelResolver(discovered.entry.definition,discovered.picklistOptions):undefined),code=definition.assessmentCode;
96
98
  const snapshot=async()=>{
97
99
  const raw=await readQuestions(input.clientId,code,readAt),current=await readQuestions(input.clientId,code,new Date(Math.max(Date.now(),parseRestInstant(readAt))).toISOString());
98
100
  const scalars={},rows={};
@@ -143,7 +145,7 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
143
145
  if(!record){
144
146
  readOverrides(observed);
145
147
  const answers=input.answers??{},clear=input.clear??[];
146
- const scalarBody=encode(definition.questions,answers,clear,'standalone'),scalarExpected=expected(definition.questions,scalarBody,answers);
148
+ const scalarBody=encode(definition.questions,answers,clear,'standalone',operationWriteLabel),scalarExpected=expected(definition.questions,scalarBody,answers);
147
149
  // The standard endpoint requires every mandatory scalar, even if this patch leaves it unchanged.
148
150
  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))){
149
151
  if(q.fieldName==='SVPPROFDOB'||!scalarTypes.has(q.questionType))fail('unsupported mandatory correction field');
@@ -151,10 +153,10 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
151
153
  if(prior?.answer_value===null||prior?.answer_value===undefined||prior?.answer_value==='')fail('mandatory correction field requires an explicit answer or clear');
152
154
  let body;
153
155
  if(q.questionType==='lookup'){
154
- const options=q.options.filter(o=>String(o.id)===String(prior.answer_value)),label=options.length===1?writeLabel?.(q,options[0]):undefined;
156
+ const options=q.options.filter(o=>String(o.id)===String(prior.answer_value)),label=options.length===1?operationWriteLabel?.(q,options[0]):undefined;
155
157
  if(!label)fail('mandatory correction lookup label unavailable');
156
158
  body=[{question_id:q.questionId,question_type:q.questionType,virt_field_name:q.fieldName,answer_value:label}];
157
- }else body=encodeAnswers([q],{[q.fieldName]:{value:prior.answer_value}},{endpoint:'standalone',writeLabel});
159
+ }else body=encodeAnswers([q],{[q.fieldName]:{value:prior.answer_value}},{endpoint:'standalone',writeLabel:operationWriteLabel});
158
160
  if(body.length!==1)fail('mandatory correction field could not be encoded');scalarBody.push(body[0]);
159
161
  scalarExpected.push({...body[0],answer_value:prior.answer_value});
160
162
  }
@@ -162,8 +164,8 @@ 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);
166
- const cells=encode(parent.children,patch.answers??{},patch.clear??[],'mobile');if(!cells.length)fail('empty row correction');
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);
168
+ const cells=encode(parent.children,patch.answers??{},patch.clear??[],'mobile',operationWriteLabel);if(!cells.length)fail('empty row correction');
167
169
  rowPlans.push({id,parentField:parent.fieldName,questionId:parent.questionId,cells});
168
170
  }
169
171
  if(!scalarBody.length&&!rowPlans.length)fail('empty entry correction');
@@ -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
  }