@choiceapps/communityservices-sdk 0.5.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 (52) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/browser/enrollment-workflow.d.mts +54 -4
  3. package/browser/enrollment-workflow.mjs +89 -33
  4. package/browser/entry-correction.mjs +53 -10
  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 +38 -1
  43. package/docs/browser-session.md +1 -1
  44. package/docs/capabilities.md +15 -2
  45. package/docs/enrollments.md +75 -3
  46. package/docs/reference.md +3 -2
  47. package/docs/troubleshooting.md +20 -0
  48. package/examples/README.md +2 -0
  49. package/examples/checked-assessment-writes.mts +14 -0
  50. package/examples/enrollment-discovery-and-correction.mts +19 -0
  51. package/examples/entry-correction-workflow.mts +8 -0
  52. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
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
+
13
+ ## 0.6.0 - 2026-11-10
14
+
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.
16
+ - Surface correction HTTP status and preserve allowlisted kind/status across retries without journaling response bodies. Retain write and read causes when reconciliation also fails.
17
+ - Add explicit `resolveEntryCorrection` with independent confirmation and two unchanged-state checks. Preserve attempted history, refuse partial/verified commits, and require a new key for corrected intent after rejection. Never reset a sent request automatically from a 4xx response.
18
+ - Clarify shared answers across assessment instruments, the public GWT enrollment catalog, and adoption for interims and scalar corrections. HUD label resolution and household-ID discovery still require authoritative metadata.
19
+ - Correct password recovery documentation: WellSky permits immediately resetting back to the previous password.
20
+
3
21
  ## 0.5.0 - 2026-11-10
4
22
 
5
23
  - Use a configured GWT enrollment catalog for creation and member preflight. Prior household enrollments, including exited entries, no longer force the known-broken REST grouped list. Failed or incomplete reads never imply absence.
@@ -79,12 +79,47 @@ export interface EntryCorrectionInput {
79
79
  subassessments?:EntryRowCorrection[];
80
80
  }
81
81
  export interface EntryCorrectionResult extends EnrollmentWorkflowResult {enrollmentKey:string;}
82
+ export interface EntryCorrectionFailure {
83
+ status?:number;
84
+ kind?:'http'|'auth'|'timeout'|'network'|'aborted'|'protocol'|'validation'|'throttled'|'cooldown';
85
+ }
86
+ export interface EntryCorrectionResolution {
87
+ kind:'not-applied';evidenceReference:string;resolvedAt:string;
88
+ }
89
+ export interface EntryCorrectionResolutionEvidence {
90
+ key:string;enrollmentKey:string;clientId:number;enrollmentId:number;effectiveDate:string;matchesBefore:true;
91
+ steps:Array<{name:string;state:'attempted'|'verified';failure?:EntryCorrectionFailure}>;
92
+ }
93
+ export interface ResolveEntryCorrectionInput {
94
+ key:string;enrollmentKey:string;clientId:number;
95
+ /** Opaque reference to independent non-application evidence, not a response body. */
96
+ evidenceReference:string;
97
+ /** Read-only confirmation using independent server/administrative evidence.
98
+ * A 4xx response plus matchesBefore alone is not sufficient. */
99
+ confirmNotApplied:(evidence:EntryCorrectionResolutionEvidence)=>boolean|Promise<boolean>;
100
+ }
101
+ export interface ResolveEntryCorrectionResult {
102
+ key:string;enrollmentKey:string;clientId:number;outcome:'rejected'|'already-rejected';resolution:EntryCorrectionResolution;
103
+ }
82
104
  export interface AdoptEnrollmentInput {key:string;clientId:number;enrollmentId:number;}
83
105
  export interface AdoptHouseholdEnrollmentInput {
84
106
  key:string;householdId:number;headClientId:number;
85
107
  /** Complete linked cohort, including already exited participants. IDs are explicit, not inferred. */
86
108
  members:Array<{clientId:number;enrollmentId:number}>;
87
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
+ }
88
123
  export interface EnrollmentWorkflowResult {
89
124
  key:string;
90
125
  outcome:'verified'|'already-present';
@@ -105,14 +140,15 @@ export interface ExitWorkflowDiscovery extends EnrollmentWorkflowDiscovery {exit
105
140
  export interface EnrollmentWorkflowStatus {
106
141
  key:string;
107
142
  kind:'entry'|'interim'|'exit'|'correction';
108
- stage:'pending'|'verified';
143
+ stage:'pending'|'verified'|'rejected';
109
144
  assessmentCode:string;
110
145
  enrollmentId?:number;
111
146
  reviewId?:number;
112
147
  projectExitDate?:string;
113
148
  exitSubgroupId?:string|null;
114
149
  recordsetIds:Record<string,number>;
115
- steps:{name:string;state:'attempted'|'verified'}[];
150
+ steps:{name:string;state:'attempted'|'verified';failure?:EntryCorrectionFailure}[];
151
+ resolution?:EntryCorrectionResolution;
116
152
  }
117
153
  export interface EnrollmentWorkflow {
118
154
  discover(input:EnrollmentDiscoveryInput):Promise<EnrollmentWorkflowDiscovery>;
@@ -123,10 +159,16 @@ export interface EnrollmentWorkflow {
123
159
  exitEnrollment(input:ExitEnrollmentInput):Promise<ExitEnrollmentResult>;
124
160
  /** Patch an open entry at its original timestamp, before any review or exit. */
125
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>;
166
+ /** Verify unchanged state and independently confirmed rejection; keeps the attempt history. */
167
+ resolveEntryCorrection(input:ResolveEntryCorrectionInput):Promise<ResolveEntryCorrectionResult>;
126
168
  /** Verify an existing standalone entry and journal it without an HMIS write. */
127
169
  adoptEnrollment(input:AdoptEnrollmentInput):Promise<EnrollmentWorkflowResult>;
128
170
  /** Verify all existing linked entries, original head and dates; no HMIS writes. */
129
- adoptHouseholdEnrollment(input:AdoptHouseholdEnrollmentInput):Promise<HouseholdEnrollmentResult>;
171
+ adoptHouseholdEnrollment(input:AdoptHouseholdEnrollmentInput|AdoptHouseholdEnrollmentFromHeadInput):Promise<HouseholdEnrollmentResult>;
130
172
  /** Reads the local journal only. A verified journal is not fresh server verification. */
131
173
  getStatus(input:{clientId:number;key:string}):Promise<EnrollmentWorkflowStatus|null>;
132
174
  createHouseholdEnrollment(input:HouseholdEnrollmentInput):Promise<HouseholdEnrollmentResult>;
@@ -217,9 +259,17 @@ export interface EnrollmentWorkflowOptions {
217
259
  }
218
260
  export class EnrollmentWorkflowError extends Error {
219
261
  readonly reason: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;
268
+ readonly fields?:string[];
269
+ readonly status?:number;
220
270
  /** Failed durable stage, when known. The original write error is available as cause in memory. */
221
271
  readonly step?:string;
222
- constructor(reason:string,options?:ErrorOptions&{step?:string});
272
+ constructor(reason:string,options?:ErrorOptions&{step?:string;code?:string;fields?:string[];status?:number;committed?:false|'unknown'});
223
273
  }
224
274
  /** An unavailable list is never evidence that no enrollment exists. */
225
275
  export class EnrollmentReadError extends EnrollmentWorkflowError {
@@ -1,7 +1,10 @@
1
1
  import {apiScope} from './connection.mjs';
2
2
  import {validateBirthDate} from './date-of-birth.mjs';
3
3
  import {expectedExitDetails} from './exit-details.mjs';
4
- import {createEntryCorrector} from './entry-correction.mjs';
4
+ 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,8 +13,18 @@ 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);};
14
- export class EnrollmentWorkflowError extends Error {constructor(reason,options){super(`Enrollment workflow: ${reason}`,options);this.name='EnrollmentWorkflowError';this.reason=reason;if(options?.step!==undefined)this.step=options.step;}}
16
+ const fail=(message,options)=>{throw new EnrollmentWorkflowError(message,{committed:false,...options});};
17
+ export class EnrollmentWorkflowError extends Error {
18
+ constructor(reason,options){
19
+ super(`Enrollment workflow: ${reason}`,options);this.name='EnrollmentWorkflowError';this.reason=reason;
20
+ if(options?.step!==undefined)this.step=options.step;
21
+ this.code=options?.code??workflowErrorCode(reason);
22
+ this.committed=options?.committed??'unknown';
23
+ if(options?.fields!==undefined)this.fields=[...options.fields];
24
+ const status=options?.status??options?.cause?.status;
25
+ if(Number.isInteger(status)&&status>=100&&status<=599)this.status=status;
26
+ }
27
+ }
15
28
  export class EnrollmentReadError extends EnrollmentWorkflowError {
16
29
  constructor({clientId,source,writeAttempted,cause}){
17
30
  super(cause instanceof EnrollmentWorkflowError?cause.reason:'enrollment list unavailable',{step:'enrollment-read',cause});this.name='EnrollmentReadError';this.code='ENROLLMENT_READ_FAILED';this.clientId=clientId;this.source=source;this.phase=writeAttempted?'reconciliation':'preflight';this.committed=writeAttempted?'unknown':false;
@@ -59,7 +72,7 @@ function compile(definition,input,{yesNoWrites,writeLabel}={}){
59
72
  const covered=new Set([...Object.keys(answers),...Object.keys(excluded),...carry,...compiledRows.map(r=>r.parentField),...compiledEnds.map(r=>r.parentField)]);
60
73
  for(const q of definition.questions){
61
74
  if(['blank','label'].includes(q.questionType))continue;
62
- 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)]});
63
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');
64
77
  if(q.fieldName in excluded&&carry.includes(q.fieldName))fail('conflicting field coverage');
65
78
  }
@@ -72,12 +85,12 @@ function compile(definition,input,{yesNoWrites,writeLabel}={}){
72
85
  if(yesNo.length)for(const q of definition.questions){
73
86
  if(!q.mandatory||['blank','label','sub_assessment'].includes(q.questionType)||yesNo.some(y=>y.virt_field_name===q.fieldName))continue;
74
87
  // Only mandatory lookups were verified live; this also keeps DOB inside its own helper.
75
- 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)]});
76
89
  const base={question_id:q.questionId,question_type:q.questionType,virt_field_name:q.fieldName};
77
90
  if(q.fieldName in answers){
78
91
  const [label]=encodeAnswers(definition.questions,{[q.fieldName]:answers[q.fieldName]},{endpoint:'standalone',writeLabel});
79
92
  const [stored]=encodeAnswers(definition.questions,{[q.fieldName]:answers[q.fieldName]},{endpoint:'mobile'});
80
- 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)]});
81
94
  mandatory.push({...base,answer_value:label.answer_value,expected:stored.answer_value});
82
95
  }else if(q.fieldName in excluded)mandatory.push({...base,answer_value:'',expected:''});
83
96
  else if(carry.includes(q.fieldName))mandatory.push({...base,carry:true});
@@ -102,7 +115,18 @@ async function enrollments(api,clientId,{enrollmentSession,writeAttempted=false}
102
115
  /** Explicit orchestration for an existing client. All writers must share the durable store.
103
116
  * No automatic retries of uncertain REST writes. This is not a cross-system transaction. */
104
117
  export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollmentSession,exitUi,memberEntryDatePort,birthDates,assessmentLocations={},yesNoWrites='mobile',writeLabel}){
105
- 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
+ };
106
130
  if(typeof discovery?.getProviderAssessments!=='function'||typeof helpers?.createInterimUpdate!=='function'||!store)fail('REST API, discovery, enrollment helpers and durable store required');
107
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');
108
132
  if(!object(assessmentLocations))fail('assessment locations must be a map by Entry/Exit type');
@@ -114,7 +138,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
114
138
  const locationsByType={HUD:{entry:'showHudEntry',interim:'showHudInterim',exit:'showHudExit'},VA:{entry:'showVaEntry',interim:'showVaInterim',exit:'showVaExit'},...structuredClone(assessmentLocations)};
115
139
  const discover=async(input,includeExit=false)=>{
116
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);
117
- 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'});
118
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');
119
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');
120
144
  const configuration=await discovery.getProviderAssessments({providerId:input.providerId});if(configuration.providerId!==input.providerId)fail('provider discovery mismatch');
@@ -129,8 +153,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
129
153
  };
130
154
  const scope=clientId=>JSON.stringify([tenantScope,'enrollment-workflow',clientId]);
131
155
  const reservationScope=clientId=>JSON.stringify([tenantScope,'household-workflow-reservation',clientId]);
132
- const correctEntry=createEntryCorrector({api,store,enrollmentSession,writeLabel,discover,scope,reservationScope,makeError:(reason,options)=>new EnrollmentWorkflowError(reason,options)});
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)});
133
159
  const run=async(kind,raw,internal={})=>{
160
+ const operation=internal.operation??{attempted:false};
134
161
  const standaloneExit=kind==='exit'&&internal.standaloneExit===true;
135
162
  fields(raw,kind==='entry'?['key','clientId','providerId','type','entryDate','assessment']:['key','enrollmentKey','clientId',kind==='exit'?'exitDate':'reviewDate','assessment',...(standaloneExit?['destination','reasonForLeaving','destinationDetails','reasonForLeavingOther']:[])]);
136
163
  const input=structuredClone(raw);key(input.key);if(!positive(input.clientId))fail('client ID required');
@@ -144,8 +171,10 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
144
171
  return locked(scope(input.clientId),async()=>{
145
172
  const reservations=await store.withLock(reservationScope(input.clientId),()=>store.read(reservationScope(input.clientId)));
146
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;
147
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');
148
- if(records.some(r=>r.stage!=='verified'&&r.key!==input.key))fail('another unfinished workflow must be reconciled first');
176
+ operation.attempted=inheritedAttempt||records.some(r=>r.key===input.key&&Object.keys(r.steps).length>0);
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');
149
178
  const matches=records.filter(r=>r.key===input.key);if(matches.length>1)fail('duplicate journal keys');let record=matches[0];
150
179
  const intentHash=hash({kind,input,...(internal.owner?{household:internal.owner}:{})});if(record&&record.intentHash!==intentHash)fail('key conflicts with previous intent');
151
180
  // Changing the yes/no endpoint after an attempt would be an endpoint switch, not reconciliation.
@@ -217,6 +246,17 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
217
246
  // that instant; one second later returns them. Only the yes/no step needs this.
218
247
  const readSaved=async()=>questions(await api.assessments.get({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:new Date(Date.parse(at)+1000).toISOString()}}));
219
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
+ }
220
260
  if(expectedHead===input.clientId&&headQuestion)checkHeadAnswer(proposedHead?.answer_value??initial.find(q=>q.virt_field_name==='HUD_RELATIONTOHOH')?.answer_value);
221
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');}
222
262
  // Resend mandatory answers with the standard yes/no write. A blank write must not hide a recorded value.
@@ -227,11 +267,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
227
267
  if(m.carry){
228
268
  const question=definition.questions.find(q=>q.fieldName===m.virt_field_name),options=question.options.filter(o=>String(o.id)===String(recorded));
229
269
  const label=options.length===1?writeLabel(question,options[0]):undefined;
230
- 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]});
231
271
  if(m.virt_field_name==='HUD_RELATIONTOHOH')checkHeadAnswer(recorded);
232
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)};
233
273
  }
234
- 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]});
235
275
  return m;
236
276
  });
237
277
  // Validate closure targets before creating enrollment/review or submitting any answers.
@@ -265,6 +305,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
265
305
  if(previous)throw new EnrollmentWorkflowError(previous.state==='verified'?'verified step changed':'uncertain write requires reconciliation',{step:name});
266
306
  record.steps[name]={state:'attempted'};await persist();
267
307
  let writeError;
308
+ operation.attempted=true;
268
309
  try{await submit(result);}catch(error){writeError=error;}
269
310
  // A write response alone never establishes success or absence. Preserve its
270
311
  // diagnostic cause in memory while reconciliation remains authoritative.
@@ -290,6 +331,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
290
331
  else {const found=await findEnrollment();if(!found.found)fail('saved enrollment missing');record.enrollmentId=entry.enrollmentId;}
291
332
  await persist();
292
333
  if(kind==='interim'){
334
+ if(!internal.readReview)operation.attempted=true;
293
335
  // The GWT helper has its own journal and authoritative reads; it owns uncertain-write reconciliation.
294
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});
295
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();
@@ -352,7 +394,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
352
394
  const known=record.steps.birthDate.value;
353
395
  result=await birthDates.verify({...dobContext,dateOfBirth:known.dateOfBirth,effectiveDate:known.effectiveDate,allowLater:true});
354
396
  }else if(plan.dateOfBirth!==undefined){
355
- 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}}:{})});
356
398
  }else result=await birthDates.verify({...dobContext,dateOfBirth:carriedBirthDate.dateOfBirth,effectiveDate:carriedBirthDate.effectiveDate,allowLater:true});
357
399
  record.steps.birthDate={state:'verified',value:{clientId:input.clientId,dateOfBirth:result.dateOfBirth,effectiveDate:result.effectiveDate}};await persist();
358
400
  }
@@ -364,6 +406,11 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
364
406
  const memberKey=(key,clientId)=>'household:'+hash({key,clientId});
365
407
  const withClients=(ids,work,index=0)=>index===ids.length?work():store.withLock(scope(ids[index]),()=>withClients(ids,work,index+1));
366
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
+ }
367
414
  fields(raw,household?['key','householdId','headClientId','members']:['key','clientId','enrollmentId']);
368
415
  const input=structuredClone(raw);key(input.key);
369
416
  if(typeof enrollmentSession?.readEnrollment!=='function')fail('authoritative enrollment session required for adoption');
@@ -387,7 +434,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
387
434
  if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'&&r.owner!==owner))fail('unfinished household workflow must be reconciled first');
388
435
  const records=await store.read(scope(target.clientId)),member=household?memberKey(input.key,target.clientId):input.key;
389
436
  if(!Array.isArray(records)||records.some(r=>r.version!==1||!object(r.steps)))fail('invalid workflow journal');
390
- if(records.some(r=>r.stage!=='verified'&&r.key!==member))fail('another unfinished workflow must be reconciled first');
437
+ if(records.some(r=>r.stage!=='verified'&&!(r.kind==='correction'&&r.stage==='rejected')&&r.key!==member))fail('another unfinished workflow must be reconciled first');
391
438
  if(records.some(r=>r.kind==='entry'&&r.enrollmentId===target.enrollmentId&&r.key!==member))fail('enrollment already journaled; use its existing entry key');
392
439
  const matches=records.filter(r=>r.key===member);if(matches.length>1)fail('duplicate journal keys');
393
440
  if(matches[0]&&(!matches[0].adopted||matches[0].intentHash!==intentHash))fail('adoption key conflicts with previous intent');
@@ -439,7 +486,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
439
486
  });
440
487
  });
441
488
  };
442
- const householdRun=async(kind,raw)=>{
489
+ const householdRun=async(kind,raw,operation={attempted:false})=>{
443
490
  if(typeof enrollmentSession?.readEnrollment!=='function'||typeof helpers.addHouseholdMembers!=='function'||typeof api.households?.get!=='function')fail('household API and authoritative enrollment session required');
444
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']:[])]);
445
492
  const input=structuredClone(raw);key(input.key);
@@ -454,8 +501,10 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
454
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);}
455
502
  // Read the cohort under its journal lock, then acquire all client locks in order.
456
503
  // Recheck under both locks so concurrent membership growth cannot escape protection.
504
+ operation.attempted=true;
457
505
  const prior=await store.withLock(householdScope(input.headClientId),()=>store.read(householdScope(input.headClientId)));
458
506
  if(!Array.isArray(prior))fail('invalid household journal');
507
+ operation.attempted=prior.some(r=>r.key===input.key);
459
508
  const lockedIds=[...new Set([input.headClientId,...ids,...prior.flatMap(r=>r.clientIds??[])])].sort((a,b)=>a-b);
460
509
  return withClients(lockedIds,()=>store.withLock(householdScope(input.headClientId),async()=>{
461
510
  const records=await store.read(householdScope(input.headClientId));
@@ -523,7 +572,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
523
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};
524
573
  // Validate every member before any record mutation. The same client locks cover
525
574
  // preflight and execution, including overlaps with the single-client workflow.
526
- 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}:{})}));
527
576
  if(kind==='addition'&&record?.entrySchemas&&canonical(record.entrySchemas)!==canonical(preflight))fail('additional member assessment configuration changed');
528
577
  if(kind==='exit'&&record?.exitSchemas&&canonical(record.exitSchemas)!==canonical(preflight))fail('exit assessment configuration changed');
529
578
  const wasVerified=record?.stage==='verified';
@@ -571,14 +620,14 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
571
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');
572
621
  }
573
622
  const head=input.members.find(m=>m.clientId===input.headClientId);
574
- const result=await run('entry',toMember(head),{locked:true,owner,headClientId:input.headClientId,findEnrollment:findMember(head.clientId)});record.members[head.clientId]=result;await persist();
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();
575
624
  // Reject unexpected members before the attachment helper is allowed to write.
576
625
  await readTarget({clientId:head.clientId,enrollmentId:result.enrollmentId},{partial:!record.attached});
577
626
  record.attachmentAttempted=true;await persist();
578
- 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)});
579
628
  const snapshot=validateSnapshot(attached.snapshot,{clientId:head.clientId,enrollmentId:result.enrollmentId});
580
629
  for(const m of snapshot.members)record.members[m.clientId]??={clientId:m.clientId,enrollmentId:m.enrollmentId};record.attached=true;await persist();
581
- 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();}
582
631
  }else if(kind==='addition'){
583
632
  if(!record.anchor){
584
633
  for(const clientId of Object.keys(catalog).map(Number).sort((a,b)=>a-b)){
@@ -592,7 +641,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
592
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');
593
642
  }
594
643
  record.attachmentAttempted=true;await persist();
595
- const attached=await helpers.addHouseholdMembers({...record.anchor,memberClientIds:ids});
644
+ operation.attempted=true;const attached=await helpers.addHouseholdMembers({...record.anchor,memberClientIds:ids});
596
645
  validateSnapshot(attached.snapshot,record.anchor);
597
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};}
598
647
  record.attached=true;await persist();await preserve();
@@ -610,13 +659,13 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
610
659
  const change={...target,providerId:context.providerId,type:context.type,entryDate:input.entryDate,cohort};
611
660
  await memberEntryDatePort.validateMemberEntryDate(change);
612
661
  record.entryDates[clientId]='attempted';record.entryDateInputs[clientId]=change;await persist();
613
- 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. */}
614
663
  }
615
664
  if(record.entryDates[clientId]==='attempted'&&record.entryDateInputs[clientId])await memberEntryDatePort.validateMemberEntryDate(record.entryDateInputs[clientId]);
616
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');
617
666
  await preserve();record.entryDates[clientId]='verified';await persist();
618
667
  }
619
- 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();}
620
669
  }else if(kind==='interim'){
621
670
  // Verify all linked enrollments before making the single household review call.
622
671
  const existingReviews=[];
@@ -631,7 +680,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
631
680
  if(existingReviews.some(Boolean)&&existingReviews.some(r=>!r))fail('partial household review requires reconciliation');
632
681
  if(existingReviews[0]&&new Set(existingReviews.map(r=>r.reviewId)).size!==ids.length)fail('duplicate household review IDs');
633
682
  const target={clientId:ids[0],enrollmentId:catalog[ids[0]].enrollmentId};
634
- 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);
635
684
  const readReview=async target=>{
636
685
  const snapshot=await readTarget(target),reviews=snapshot.reviews.filter(r=>r.type==='Update'&&restDate(r.reviewDate)===restDate(input.reviewDate));
637
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');
@@ -641,7 +690,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
641
690
  // Pin every member's distinct review ID before writing any member's answers.
642
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();}
643
692
  if(new Set(ids.map(id=>record.members[id].reviewId)).size!==ids.length)fail('duplicate household review IDs');
644
- 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();}
645
694
  }else if(kind==='exit'){
646
695
  const target={clientId:ids[0],enrollmentId:catalog[ids[0]].enrollmentId};
647
696
  const normalizeLabel=value=>typeof value==='string'?value.replace(/\s*\(HUD\)\s*$/,'').trim():value;
@@ -672,7 +721,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
672
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}:{})};
673
722
  await exitUi.validateExit(exitInput);
674
723
  record.exitStep='attempted';await persist();
675
- 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. */}
676
725
  observed=await inspect();if(!observed.found)fail('uncertain household exit requires reconciliation');
677
726
  }
678
727
  await validateHeadCoverage();await preserve();
@@ -680,7 +729,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
680
729
  const readExit=async target=>{
681
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);
682
731
  };
683
- 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();}
684
733
  }
685
734
  if(kind==='exit'||kind==='addition')await preserve();
686
735
  await validateHeadCoverage();
@@ -689,12 +738,16 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
689
738
  }));
690
739
  };
691
740
  return {
692
- correctEntry,
693
- adoptEnrollment:input=>adopt(false,input),adoptHouseholdEnrollment:input=>adopt(true,input),
694
- addHouseholdMembers:input=>householdRun('addition',input),
695
- createHouseholdEnrollment:input=>householdRun('entry',input),createHouseholdInterim:input=>householdRun('interim',input),exitHousehold:input=>householdRun('exit',input),
696
- 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)),
697
749
  async getHouseholdStatus(input){
750
+ return execute('getHouseholdStatus',async()=>{
698
751
  fields(input,['headClientId','key']);if(!positive(input.headClientId))fail('head client ID required');key(input.key);
699
752
  return store.withLock(householdScope(input.headClientId),async()=>{
700
753
  const records=await store.read(householdScope(input.headClientId));if(!Array.isArray(records))fail('invalid household journal');
@@ -702,15 +755,18 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
702
755
  if(record.version!==1||!object(record.members))fail('invalid household journal');
703
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]??{})}))};
704
757
  });
758
+ });
705
759
  },
706
- 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})),
707
761
  async getStatus(input){
762
+ return execute('getStatus',async()=>{
708
763
  fields(input,['clientId','key']);if(!positive(input.clientId))fail('client ID required');key(input.key);
709
764
  return store.withLock(scope(input.clientId),async()=>{
710
765
  const records=await store.read(scope(input.clientId));if(!Array.isArray(records))fail('invalid workflow journal');
711
766
  const matches=records.filter(r=>r.key===input.key);if(matches.length>1)fail('duplicate journal keys');const r=matches[0];if(!r)return null;
712
767
  if(r.version!==1||!object(r.steps))fail('invalid workflow journal');
713
- 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}:{}),recordsetIds:structuredClone(r.rows),steps:Object.entries(r.steps).map(([name,step])=>({name,state:step.state}))};
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)}:{})}))};
769
+ });
714
770
  });
715
771
  },
716
772
  };
@@ -6,6 +6,12 @@ const canonical=v=>JSON.stringify(v,(_,x)=>object(x)?Object.fromEntries(Object.k
6
6
  const hash=v=>createHash('sha256').update(canonical(v)).digest('hex');
7
7
  const positive=v=>Number.isSafeInteger(v)&&v>0;
8
8
  const scalarTypes=new Set(['lookup','yes_no','date','int','money','textbox','textarea']);
9
+ export function correctionFailureDetails(error){
10
+ const failure={};
11
+ if(Number.isInteger(error?.status)&&error.status>=100&&error.status<=599)failure.status=error.status;
12
+ if(['http','auth','timeout','network','aborted','protocol','validation','throttled','cooldown'].includes(error?.kind))failure.kind=error.kind;
13
+ return failure;
14
+ }
9
15
 
10
16
  /** Internal correction writer. The workflow supplies its existing client locks and journal scopes. */
11
17
  export function createEntryCorrector({api,store,enrollmentSession,writeLabel,discover,scope,reservationScope,makeError}){
@@ -21,6 +27,8 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
21
27
  const encode=(questions,answers,clear,endpoint)=>{
22
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');
23
29
  const index=new Map(questions.map(q=>[q.fieldName,q]));
30
+ const unsupportedClears=clear.filter(field=>index.get(field)?.questionType==='yes_no');
31
+ if(unsupportedClears.length)throw makeError('yes/no answers cannot be cleared',{code:'UNSUPPORTED_YES_NO_CLEAR',fields:unsupportedClears});
24
32
  for(const field of [...Object.keys(answers),...clear]){
25
33
  const q=index.get(field);
26
34
  if(!q||!scalarTypes.has(q.questionType)||endpoint==='mobile'&&q.questionType==='yes_no')fail('unsupported correction field type');
@@ -42,21 +50,37 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
42
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');
43
51
  return rows;
44
52
  };
45
- return async raw=>{
46
- fields(raw,['key','enrollmentKey','clientId','answers','clear','subassessments']);
47
- const input=structuredClone(raw);key(input.key);key(input.enrollmentKey);
53
+ const run=async(mode,raw,operation={attempted:false})=>{
54
+ const resolving=mode==='resolve';
55
+ fields(raw,resolving?['key','enrollmentKey','clientId','evidenceReference','confirmNotApplied']:['key','enrollmentKey','clientId','answers','clear','subassessments']);
56
+ const {confirmNotApplied,...serializable}=raw;
57
+ const input=structuredClone(serializable);key(input.key);key(input.enrollmentKey);
58
+ if(resolving){key(input.evidenceReference);if(typeof confirmNotApplied!=='function')fail('independent non-application confirmation required');}
48
59
  if(!positive(input.clientId)||!Array.isArray(input.subassessments??[]))fail('invalid correction client or rows');
49
60
  if(typeof api.assessments?.create!=='function'||typeof enrollmentSession?.readEnrollment!=='function')fail('standard assessment writes and authoritative enrollment reader required for corrections');
50
61
  return store.withLock(scope(input.clientId),async()=>{
51
62
  const reservations=await store.withLock(reservationScope(input.clientId),()=>store.read(reservationScope(input.clientId)));
52
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;
53
65
  const records=await store.read(scope(input.clientId));
54
66
  if(!Array.isArray(records)||records.some(r=>!object(r)||r.version!==1||!object(r.steps)))fail('invalid workflow journal');
55
- if(records.some(r=>r.stage!=='verified'&&r.key!==input.key))fail('another unfinished workflow must be reconciled first');
67
+ if(!resolving)operation.attempted=records.some(r=>r.key===input.key&&Object.keys(r.steps).length>0);
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');
56
69
  const entries=records.filter(r=>r.kind==='entry'&&r.key===input.enrollmentKey&&r.stage==='verified');
57
70
  if(entries.length!==1)fail('verified enrollment workflow required');const entry=entries[0];
58
71
  const matches=records.filter(r=>r.key===input.key);if(matches.length>1)fail('duplicate correction journal keys');let record=matches[0];
59
- const intentHash=hash(input);if(record&&(record.kind!=='correction'||record.intentHash!==intentHash))fail('correction key conflicts with previous intent');
72
+ const intentHash=hash(input);
73
+ if(resolving){
74
+ if(!record||record.kind!=='correction'||record.enrollmentKey!==input.enrollmentKey)fail('matching correction journal required');
75
+ if(record.stage==='verified')fail('verified correction cannot be rejected');
76
+ if(record.stage==='rejected'){
77
+ if(record.resolution?.evidenceReference!==input.evidenceReference)fail('resolution evidence reference changed');
78
+ return {key:input.key,enrollmentKey:input.enrollmentKey,clientId:input.clientId,outcome:'already-rejected',resolution:structuredClone(record.resolution)};
79
+ }
80
+ }else{
81
+ if(record?.kind==='correction'&&record.stage==='rejected')throw makeError('correction was resolved as not applied; use a new key for a corrected intent',{code:'ENTRY_CORRECTION_REJECTED'});
82
+ if(record&&(record.kind!=='correction'||record.intentHash!==intentHash))fail('correction key conflicts with previous intent');
83
+ }
60
84
  if(!record&&records.some(r=>r.enrollmentId===entry.enrollmentId&&!['entry','correction'].includes(r.kind)))fail('entry correction must precede interim or exit workflows');
61
85
  const {providerId,type,entryDate}=entry.context,at=new Date(parseRestInstant(entryDate)).toISOString(),readAt=new Date(parseRestInstant(at)+1000).toISOString();
62
86
  const target={clientId:input.clientId,enrollmentId:entry.enrollmentId};
@@ -99,6 +123,17 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
99
123
  return {raw,scalars,rows};
100
124
  };
101
125
  const observed=await snapshot();
126
+ if(resolving){
127
+ if(record.stage!=='pending'||!Object.values(record.steps).some(s=>s.state==='attempted'))fail('attempted correction required for resolution');
128
+ const unchanged=state=>canonical({scalars:state.scalars,rows:state.rows})===canonical(record.before);
129
+ if(!unchanged(observed))fail('correction state differs from its pre-write snapshot; non-application cannot be resolved');
130
+ const evidence={clientId:input.clientId,enrollmentId:entry.enrollmentId,enrollmentKey:input.enrollmentKey,key:input.key,effectiveDate:at,matchesBefore:true,steps:Object.entries(record.steps).map(([name,s])=>({name,state:s.state,...(s.failure?{failure:correctionFailureDetails(s.failure)}:{})}))};
131
+ if(await confirmNotApplied(evidence)!==true)fail('independent non-application confirmation required');
132
+ await verifyEnrollment();if(!unchanged(await snapshot()))fail('correction state changed during resolution');
133
+ record.resolution={kind:'not-applied',evidenceReference:input.evidenceReference,resolvedAt:new Date().toISOString()};record.stage='rejected';
134
+ await store.write(scope(input.clientId),structuredClone(records));
135
+ return {key:input.key,enrollmentKey:input.enrollmentKey,clientId:input.clientId,outcome:'rejected',resolution:structuredClone(record.resolution)};
136
+ }
102
137
  const readOverrides=state=>{
103
138
  for(const q of Object.values(entry.correctedScalars??{})){
104
139
  const actual=state.scalars[q.virt_field_name];if(!actual||actual.value!==value(q.question_type,q.answer_value)||actual.value!==null&&actual.date!==parseRestInstant(at))fail('verified entry correction changed');
@@ -129,7 +164,7 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
129
164
  for(const patch of input.subassessments??[]){
130
165
  fields(patch,['key','parentField','answers','clear']);key(patch.key);
131
166
  const id=entry.rows[patch.key],parent=definition.questions.find(q=>q.fieldName===patch.parentField&&q.questionType==='sub_assessment');
132
- if(!positive(id)||ids.has(id)||!parent||!observed.rows[parent.fieldName]?.[id])fail('correction requires an existing entry row key and matching parent');ids.add(id);
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);
133
168
  const cells=encode(parent.children,patch.answers??{},patch.clear??[],'mobile');if(!cells.length)fail('empty row correction');
134
169
  rowPlans.push({id,parentField:parent.fieldName,questionId:parent.questionId,cells});
135
170
  }
@@ -145,12 +180,19 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
145
180
  return scalarsMatch&&canonical(state.rows)===canonical(want.rows);
146
181
  };
147
182
  const step=async(name,submit)=>{
148
- if(await inspect(name)){record.steps[name]={state:'verified'};await persist();return;}
149
- if(record.steps[name])throw makeError('uncertain entry correction requires reconciliation',{step:name});
183
+ const uncertain=cause=>makeError('uncertain entry correction requires reconciliation',{step:name,code:'ENTRY_CORRECTION_UNCERTAIN',status:record.steps[name]?.failure?.status,...(cause===undefined?{}:{cause})});
184
+ let found;try{found=await inspect(name);}catch(readError){if(record.steps[name])throw uncertain(readError);throw readError;}
185
+ if(found){record.steps[name]={state:'verified'};await persist();return;}
186
+ if(record.steps[name])throw uncertain();
150
187
  if(!await inspect(null))fail('entry changed since correction preflight');
151
188
  await verifyEnrollment();record.steps[name]={state:'attempted'};await persist();let cause;
152
- try{await submit();}catch(e){cause=e;}
153
- if(!await inspect(name))throw makeError('uncertain entry correction requires reconciliation',{step:name,cause});
189
+ operation.attempted=true;try{await submit();}catch(e){
190
+ cause=e;const failure=correctionFailureDetails(e);
191
+ if(Object.keys(failure).length){record.steps[name].failure=failure;await persist();}
192
+ }
193
+ let saved;
194
+ try{saved=await inspect(name);}catch(readError){throw uncertain(cause===undefined?readError:new AggregateError([cause,readError],'Correction write and reconciliation read failed'));}
195
+ if(!saved)throw uncertain(cause);
154
196
  record.steps[name]={state:'verified'};await persist();
155
197
  };
156
198
  if(record.scalarBody.length)await step('scalars',()=>api.assessments.create({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:at},body:record.scalarBody}));
@@ -161,4 +203,5 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
161
203
  record.stage='verified';await persist();return result('verified');
162
204
  });
163
205
  };
206
+ return {correctEntry:(raw,operation)=>run('correct',raw,operation),resolveEntryCorrection:raw=>run('resolve',raw)};
164
207
  }