@choiceapps/communityservices-sdk 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 - 2026-11-10
4
+
5
+ - 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.
6
+ - Surface correction HTTP status and preserve allowlisted kind/status across retries without journaling response bodies. Retain write and read causes when reconciliation also fails.
7
+ - 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.
8
+ - 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.
9
+ - Correct password recovery documentation: WellSky permits immediately resetting back to the previous password.
10
+
3
11
  ## 0.5.0 - 2026-11-10
4
12
 
5
13
  - 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,6 +79,28 @@ 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;
@@ -105,14 +127,15 @@ export interface ExitWorkflowDiscovery extends EnrollmentWorkflowDiscovery {exit
105
127
  export interface EnrollmentWorkflowStatus {
106
128
  key:string;
107
129
  kind:'entry'|'interim'|'exit'|'correction';
108
- stage:'pending'|'verified';
130
+ stage:'pending'|'verified'|'rejected';
109
131
  assessmentCode:string;
110
132
  enrollmentId?:number;
111
133
  reviewId?:number;
112
134
  projectExitDate?:string;
113
135
  exitSubgroupId?:string|null;
114
136
  recordsetIds:Record<string,number>;
115
- steps:{name:string;state:'attempted'|'verified'}[];
137
+ steps:{name:string;state:'attempted'|'verified';failure?:EntryCorrectionFailure}[];
138
+ resolution?:EntryCorrectionResolution;
116
139
  }
117
140
  export interface EnrollmentWorkflow {
118
141
  discover(input:EnrollmentDiscoveryInput):Promise<EnrollmentWorkflowDiscovery>;
@@ -123,6 +146,8 @@ export interface EnrollmentWorkflow {
123
146
  exitEnrollment(input:ExitEnrollmentInput):Promise<ExitEnrollmentResult>;
124
147
  /** Patch an open entry at its original timestamp, before any review or exit. */
125
148
  correctEntry(input:EntryCorrectionInput):Promise<EntryCorrectionResult>;
149
+ /** Verify unchanged state and independently confirmed rejection; keeps the attempt history. */
150
+ resolveEntryCorrection(input:ResolveEntryCorrectionInput):Promise<ResolveEntryCorrectionResult>;
126
151
  /** Verify an existing standalone entry and journal it without an HMIS write. */
127
152
  adoptEnrollment(input:AdoptEnrollmentInput):Promise<EnrollmentWorkflowResult>;
128
153
  /** Verify all existing linked entries, original head and dates; no HMIS writes. */
@@ -217,9 +242,12 @@ export interface EnrollmentWorkflowOptions {
217
242
  }
218
243
  export class EnrollmentWorkflowError extends Error {
219
244
  readonly reason:string;
245
+ readonly code?:string;
246
+ readonly fields?:string[];
247
+ readonly status?:number;
220
248
  /** Failed durable stage, when known. The original write error is available as cause in memory. */
221
249
  readonly step?:string;
222
- constructor(reason:string,options?:ErrorOptions&{step?:string});
250
+ constructor(reason:string,options?:ErrorOptions&{step?:string;code?:string;fields?:string[];status?:number});
223
251
  }
224
252
  /** An unavailable list is never evidence that no enrollment exists. */
225
253
  export class EnrollmentReadError extends EnrollmentWorkflowError {
@@ -1,7 +1,7 @@
1
1
  import {apiScope} from './connection.mjs';
2
2
  import {validateBirthDate} from './date-of-birth.mjs';
3
3
  import {expectedExitDetails} from './exit-details.mjs';
4
- import {createEntryCorrector} from './entry-correction.mjs';
4
+ import {createEntryCorrector,correctionFailureDetails} from './entry-correction.mjs';
5
5
  import {createHash} from 'node:crypto';
6
6
  import {readProgramCatalog,encodeAnswers,encodeNewSubassessmentRow,toItems,parseRestInstant} from '../dist/esm/index.js';
7
7
 
@@ -11,7 +11,16 @@ const entryExitTypes=['HUD','Basic','Standard','RHY','VA','Basic Center Program
11
11
  const canonical=v=>JSON.stringify(v,(_,x)=>object(x)?Object.fromEntries(Object.keys(x).sort().map(k=>[k,x[k]])):x);
12
12
  const hash=v=>createHash('sha256').update(canonical(v)).digest('hex');
13
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;}}
14
+ export class EnrollmentWorkflowError extends Error {
15
+ constructor(reason,options){
16
+ super(`Enrollment workflow: ${reason}`,options);this.name='EnrollmentWorkflowError';this.reason=reason;
17
+ if(options?.step!==undefined)this.step=options.step;
18
+ if(options?.code!==undefined)this.code=options.code;
19
+ if(options?.fields!==undefined)this.fields=[...options.fields];
20
+ const status=options?.status??options?.cause?.status;
21
+ if(Number.isInteger(status)&&status>=100&&status<=599)this.status=status;
22
+ }
23
+ }
15
24
  export class EnrollmentReadError extends EnrollmentWorkflowError {
16
25
  constructor({clientId,source,writeAttempted,cause}){
17
26
  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;
@@ -129,7 +138,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
129
138
  };
130
139
  const scope=clientId=>JSON.stringify([tenantScope,'enrollment-workflow',clientId]);
131
140
  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)});
141
+ const {correctEntry,resolveEntryCorrection}=createEntryCorrector({api,store,enrollmentSession,writeLabel,discover,scope,reservationScope,makeError:(reason,options)=>new EnrollmentWorkflowError(reason,options)});
133
142
  const run=async(kind,raw,internal={})=>{
134
143
  const standaloneExit=kind==='exit'&&internal.standaloneExit===true;
135
144
  fields(raw,kind==='entry'?['key','clientId','providerId','type','entryDate','assessment']:['key','enrollmentKey','clientId',kind==='exit'?'exitDate':'reviewDate','assessment',...(standaloneExit?['destination','reasonForLeaving','destinationDetails','reasonForLeavingOther']:[])]);
@@ -145,7 +154,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
145
154
  const reservations=await store.withLock(reservationScope(input.clientId),()=>store.read(reservationScope(input.clientId)));
146
155
  if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'&&r.owner!==internal.owner))fail('unfinished household workflow must be reconciled first');
147
156
  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');
157
+ 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
158
  const matches=records.filter(r=>r.key===input.key);if(matches.length>1)fail('duplicate journal keys');let record=matches[0];
150
159
  const intentHash=hash({kind,input,...(internal.owner?{household:internal.owner}:{})});if(record&&record.intentHash!==intentHash)fail('key conflicts with previous intent');
151
160
  // Changing the yes/no endpoint after an attempt would be an endpoint switch, not reconciliation.
@@ -387,7 +396,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
387
396
  if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'&&r.owner!==owner))fail('unfinished household workflow must be reconciled first');
388
397
  const records=await store.read(scope(target.clientId)),member=household?memberKey(input.key,target.clientId):input.key;
389
398
  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');
399
+ 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
400
  if(records.some(r=>r.kind==='entry'&&r.enrollmentId===target.enrollmentId&&r.key!==member))fail('enrollment already journaled; use its existing entry key');
392
401
  const matches=records.filter(r=>r.key===member);if(matches.length>1)fail('duplicate journal keys');
393
402
  if(matches[0]&&(!matches[0].adopted||matches[0].intentHash!==intentHash))fail('adoption key conflicts with previous intent');
@@ -690,6 +699,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
690
699
  };
691
700
  return {
692
701
  correctEntry,
702
+ resolveEntryCorrection,
693
703
  adoptEnrollment:input=>adopt(false,input),adoptHouseholdEnrollment:input=>adopt(true,input),
694
704
  addHouseholdMembers:input=>householdRun('addition',input),
695
705
  createHouseholdEnrollment:input=>householdRun('entry',input),createHouseholdInterim:input=>householdRun('interim',input),exitHousehold:input=>householdRun('exit',input),
@@ -710,7 +720,7 @@ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollment
710
720
  const records=await store.read(scope(input.clientId));if(!Array.isArray(records))fail('invalid workflow journal');
711
721
  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
722
  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}))};
723
+ 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)}:{})}))};
714
724
  });
715
725
  },
716
726
  };
@@ -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,9 +50,12 @@ 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)=>{
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()=>{
@@ -52,11 +63,22 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
52
63
  if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'))fail('unfinished household workflow must be reconciled first');
53
64
  const records=await store.read(scope(input.clientId));
54
65
  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');
66
+ 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
67
  const entries=records.filter(r=>r.kind==='entry'&&r.key===input.enrollmentKey&&r.stage==='verified');
57
68
  if(entries.length!==1)fail('verified enrollment workflow required');const entry=entries[0];
58
69
  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');
70
+ const intentHash=hash(input);
71
+ if(resolving){
72
+ if(!record||record.kind!=='correction'||record.enrollmentKey!==input.enrollmentKey)fail('matching correction journal required');
73
+ if(record.stage==='verified')fail('verified correction cannot be rejected');
74
+ if(record.stage==='rejected'){
75
+ if(record.resolution?.evidenceReference!==input.evidenceReference)fail('resolution evidence reference changed');
76
+ return {key:input.key,enrollmentKey:input.enrollmentKey,clientId:input.clientId,outcome:'already-rejected',resolution:structuredClone(record.resolution)};
77
+ }
78
+ }else{
79
+ 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'});
80
+ if(record&&(record.kind!=='correction'||record.intentHash!==intentHash))fail('correction key conflicts with previous intent');
81
+ }
60
82
  if(!record&&records.some(r=>r.enrollmentId===entry.enrollmentId&&!['entry','correction'].includes(r.kind)))fail('entry correction must precede interim or exit workflows');
61
83
  const {providerId,type,entryDate}=entry.context,at=new Date(parseRestInstant(entryDate)).toISOString(),readAt=new Date(parseRestInstant(at)+1000).toISOString();
62
84
  const target={clientId:input.clientId,enrollmentId:entry.enrollmentId};
@@ -99,6 +121,17 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
99
121
  return {raw,scalars,rows};
100
122
  };
101
123
  const observed=await snapshot();
124
+ if(resolving){
125
+ if(record.stage!=='pending'||!Object.values(record.steps).some(s=>s.state==='attempted'))fail('attempted correction required for resolution');
126
+ const unchanged=state=>canonical({scalars:state.scalars,rows:state.rows})===canonical(record.before);
127
+ if(!unchanged(observed))fail('correction state differs from its pre-write snapshot; non-application cannot be resolved');
128
+ 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)}:{})}))};
129
+ if(await confirmNotApplied(evidence)!==true)fail('independent non-application confirmation required');
130
+ await verifyEnrollment();if(!unchanged(await snapshot()))fail('correction state changed during resolution');
131
+ record.resolution={kind:'not-applied',evidenceReference:input.evidenceReference,resolvedAt:new Date().toISOString()};record.stage='rejected';
132
+ await store.write(scope(input.clientId),structuredClone(records));
133
+ return {key:input.key,enrollmentKey:input.enrollmentKey,clientId:input.clientId,outcome:'rejected',resolution:structuredClone(record.resolution)};
134
+ }
102
135
  const readOverrides=state=>{
103
136
  for(const q of Object.values(entry.correctedScalars??{})){
104
137
  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');
@@ -145,12 +178,19 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
145
178
  return scalarsMatch&&canonical(state.rows)===canonical(want.rows);
146
179
  };
147
180
  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});
181
+ const uncertain=cause=>makeError('uncertain entry correction requires reconciliation',{step:name,code:'ENTRY_CORRECTION_UNCERTAIN',status:record.steps[name]?.failure?.status,...(cause===undefined?{}:{cause})});
182
+ let found;try{found=await inspect(name);}catch(readError){if(record.steps[name])throw uncertain(readError);throw readError;}
183
+ if(found){record.steps[name]={state:'verified'};await persist();return;}
184
+ if(record.steps[name])throw uncertain();
150
185
  if(!await inspect(null))fail('entry changed since correction preflight');
151
186
  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});
187
+ try{await submit();}catch(e){
188
+ cause=e;const failure=correctionFailureDetails(e);
189
+ if(Object.keys(failure).length){record.steps[name].failure=failure;await persist();}
190
+ }
191
+ let saved;
192
+ try{saved=await inspect(name);}catch(readError){throw uncertain(cause===undefined?readError:new AggregateError([cause,readError],'Correction write and reconciliation read failed'));}
193
+ if(!saved)throw uncertain(cause);
154
194
  record.steps[name]={state:'verified'};await persist();
155
195
  };
156
196
  if(record.scalarBody.length)await step('scalars',()=>api.assessments.create({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:at},body:record.scalarBody}));
@@ -161,4 +201,5 @@ export function createEntryCorrector({api,store,enrollmentSession,writeLabel,dis
161
201
  record.stage='verified';await persist();return result('verified');
162
202
  });
163
203
  };
204
+ return {correctEntry:raw=>run('correct',raw),resolveEntryCorrection:raw=>run('resolve',raw)};
164
205
  }
@@ -29,7 +29,7 @@ The default metadata cache TTL is 60 seconds with 100 entries per client. Config
29
29
 
30
30
  WellSky requires a client context and known assessment code for these reads. There is no documented global assessment-list or standalone picklist-list endpoint. Mobile option metadata has no effective-date filter, so definitions explicitly identify its options as current even when the generic assessment read is dated. Metadata also does not provide a complete questionnaire branching engine.
31
31
 
32
- Use the raw `assessments.get`, `assessmentsMobile.getAnswers`, and `enrollments.getAssessment` methods for recorded answers. Use `encodeAnswers` and `encodeNewSubassessmentRow` for endpoint-specific encoding. Mobile lookups use option IDs; standalone/enrollment lookups require an explicit `writeLabel` resolver with tenant-verified labels. Raw option names are not assumed to be valid write labels. Provider answers are refused for mobile writes because that endpoint can silently discard them. Answer-state code mappings and multi-value serialization are caller-supplied; a refusal is never converted to `No`.
32
+ Use the raw `assessments.get`, `assessmentsMobile.getAnswers`, and `enrollments.getAssessment` methods for recorded answers. Use `encodeAnswers` and `encodeNewSubassessmentRow` for endpoint-specific encoding. Mobile lookups use option IDs; standalone/enrollment lookups require an explicit `writeLabel` resolver with tenant-verified labels. Raw option names are not assumed to be valid write labels. Some HUD-marked choices require `(HUD)` in the full write label even when metadata `name` omits it. Do not infer this from trailing whitespace or append the suffix to every option. Continue supplying tenant-verified labels until a label resolver has authoritative metadata; recorded `answer_value_text` is useful evidence for the corresponding option. Provider answers are refused for mobile writes because that endpoint can silently discard them. Answer-state code mappings and multi-value serialization are caller-supplied; a refusal is never converted to `No`.
33
33
 
34
34
  ## Yes/no answer acceptance
35
35
 
@@ -48,6 +48,12 @@ Reproduced on November 2, 2026 in a sandbox tenant with synthetic clients:
48
48
 
49
49
  The enrollment workflow writes yes/no answers through the mobile batch by default. That keeps their stage pending because the saved values never verify. Construct it with `yesNoWrites: 'standard'` and a `writeLabel` resolver to send them through `assessments.create` instead; see [the enrollment workflow](enrollments.md#yesno-answers). It never treats an empty yes/no answer as `No`, skips a requested field, or switches endpoints for a stage that was already attempted. For a minimal reproduction, [enable private error diagnostics](troubleshooting.md#inspect-an-upstream-error-privately) and retain the question's current metadata.
50
50
 
51
+ ## Shared questions across assessment instruments
52
+
53
+ An entry assessment and a standalone form can reference the same stored question. Changing `assessmentCode` does not make a second answer at the same client/question/effective instant available for a mobile insert. The integrator reported this for `HPTARGETINGSCORETOTAL` on an entry and form 1002: when the stored value differed, the mobile batch was rejected. Compare question identifiers and effective dates, not only form names.
54
+
55
+ Read the existing answer before planning a mobile scalar insert. If it already exists at that instant with another value, use a supported standard correction/update path after resolving the original operation; do not move its timestamp to evade the collision. Raw `assessmentsMobile.addAnswer` remains a raw write and performs no implicit read or cross-form collision check. Never switch endpoints to retry an uncertain batch.
56
+
51
57
  ## Race and ethnicity combinations
52
58
 
53
59
  `encodeAnswers` recognizes the standard `HUD_RACEETHNICITY` field. It resolves single-category and multiple-category answers to the tenant's `RACE_ETH_COMBO_*` option IDs. A single White answer uses `RACE_ETH_COMBO_WHI`, even when the caller supplies `RACE_ETH_WHI`. Atomic option IDs can read back with a label while appearing blank in the UI.
@@ -97,7 +97,7 @@ On the expired-login form, the helper appends the character to the original `HMI
97
97
 
98
98
  Use one shared, durable recovery store for every caller of the same UI account. Multi-host deployments must override the default with shared durable storage and locking. Keep using an existing custom recovery store if it has a pending recovery; changing stores does not reconcile it. The per-account lock covers the entire recovery. Its journal contains stages and the append character, never either password. The original must be 8–49 characters when a reset is needed so the temporary password fits the observed 50-character limit. A 50-character password can still log in normally. Keep the original credential and append character unchanged while a recovery is pending. The SDK sanitizes login errors; do not enable Playwright tracing, request-body capture, or debug logging containing credentials.
99
99
 
100
- A saved temporary-password attempt can resume with one authentication check of that temporary password. A saved restoration attempt can resume with one authentication check of the original. Neither uncertain password write is repeated. An interrupted or failed authentication check leaves a blocking journal stage and raises `RECOVERY_UNCERTAIN`; reconcile the account and journal before proceeding. A process crash may also leave the file store's exclusive lock. Do not delete journals or automatically expire locks to force retries. If server policy rejects restoring the original, the helper stops and the temporary password may remain active.
100
+ A saved temporary-password attempt can resume with one authentication check of that temporary password. A saved restoration attempt can resume with one authentication check of the original. Neither uncertain password write is repeated. An interrupted or failed authentication check leaves a blocking journal stage and raises `RECOVERY_UNCERTAIN`; reconcile the account and journal before proceeding. A process crash may also leave the file store's exclusive lock. Do not delete journals or automatically expire locks to force retries. WellSky permits immediately resetting back to the previous password. Password history does not prevent the restore-original step.
101
101
 
102
102
  Both UI actions use GWT-RPC: the shipped expired-login handler calls `AuthenticationService.changePasswordHeartbeat`, while the user-settings restoration was observed calling `UserService.updateUserPassword` with HTTP 200 / GWT OK. The former uses the limited session established by the expired login. The helper currently drives the live forms to generate fresh requests. A standalone RPC-only password adapter has not been implemented or tested; captured password payloads must not be stored or replayed. See [sandbox findings](capabilities.md).
103
103
 
@@ -39,7 +39,7 @@ The generated client covers the pinned V2/V3 Swagger operations. The following f
39
39
  | Client Notes, goals, action steps | `createCasePlans` plus raw REST methods | Journaled create/update and supported-field verification; helper supplies clientId on goal updates; unreadable follow-up fields excluded |
40
40
  | Goal case notes | `session.workflows.createCaseNote` | Optional source key rejects changed intent and verifies saved note ID; legacy unkeyed matching remains; single client only. REST goal response does not expose these notes |
41
41
  | Multi-client/multi-case notes | No packaged bulk helper | Do not infer note participants from unrelated `households` nested in a goal response |
42
- | Expired UI password | Headless login, enabled by default | Temporary suffix then restore original, durable recovery stages, fresh-login verification; policy rejection may leave temporary password active |
42
+ | Expired UI password | Headless login, enabled by default | Temporary suffix then restore original, durable recovery stages, fresh-login verification; WellSky permits immediate restoration of the previous password |
43
43
  | Session expiration | Managed session plus durable workflow journals | Reauthenticate and report interruption; caller retries original intent for reconciliation, not automatic callback replay |
44
44
 
45
45
  ## Known server gaps
@@ -69,7 +69,7 @@ These observations explain the current contracts. They are dated acceptance resu
69
69
  | Household exits and head coverage, October 23, 2026 | An invalid early head exit was blocked before writes. Valid staggered exits and an all-member exit preserved dates, original heads, and selected subgroups; retries made no extra record writes. | Earlier server-accepted orphaned groups were invalid under the current SDK contract. See [head validation feedback](vendor-issues/household-head-validation.md) and [enrollments](enrollments.md). |
70
70
  | Attachment identity and content, October 24, 2026 | Profile, Case Plans, and goal files retained their IDs across fresh helpers, and downloaded bytes matched the originals. | Does not establish anonymous access or sharing permissions. REST authentication remained a separate failure. See [files and notes](files-and-notes.md). |
71
71
  | SSVF conditional forms, October 23, 2026 | Form inspection checked conditional branches and required-date validation without saving; four REST-supported dependencies were then saved and read back, with zero duplicate writes on same-key retries. | This is a dated rules snapshot, not exhaustive financial assistance or funding-allocation acceptance. See [SSVF rules](../SSVF.md) and [services](services.md). |
72
- | Expired-password recovery, October 23, 2026 | An expired account completed temporary-password recovery, restored its original password, and authenticated in a fresh context. | Account policy can reject a recovery stage; retain the durable recovery journal. See [browser sessions](browser-session.md). |
72
+ | Expired-password recovery, October 23, 2026 | An expired account completed temporary-password recovery, restored its original password, and authenticated in a fresh context. | WellSky permits immediate restoration of the previous password; retain the durable recovery journal for interrupted operations. See [browser sessions](browser-session.md). |
73
73
 
74
74
  Independent needs and AIRS/service-type discovery use UI/GWT helpers. See [independent needs](independent-needs.md) for the creation, counted catalog, and retry contracts. The pinned Swagger exposes need reads but no independent-need creation.
75
75
 
@@ -148,14 +148,36 @@ const corrected = await workflow.correctEntry({
148
148
  });
149
149
  ```
150
150
 
151
- `answers` uses the ordinary answer and option selectors. `clear` explicitly removes a recorded value; an omitted field stays unchanged. Clearing a yes/no answer is distinct from answering `No`. Do not use `notApplicable` to clear an old answer. Every row key must identify an existing row returned by the original entry operation. The correction never creates a second row, deletes a row, or changes the enrollment date.
151
+ `answers` uses the ordinary answer and option selectors. `clear` explicitly removes a recorded value; an omitted field stays unchanged. Yes/no fields cannot appear in `clear`: the SDK throws `UNSUPPORTED_YES_NO_CLEAR`, with the affected `fields`, before any correction write or new journal record. Use `{value: false}` to answer `No`; it does not mean blank. No alternative yes/no blanking route has been verified. Do not use `notApplicable` to clear an old answer. Every row key must identify an existing row returned by the original entry operation. The correction never creates a second row, deletes a row, or changes the enrollment date.
152
152
 
153
- Scalars use the standard assessment endpoint, which supports same-date updates. The batch includes every mandatory scalar, preserving its current value unless the patch explicitly answers or clears it. Missing mandatory values require an explicit answer or clear; lookup values require verified write labels. Readback checks one second after the original instant so stored yes/no values are visible.
153
+ Scalars use the standard assessment endpoint, which supports same-date updates. The batch includes every mandatory scalar, preserving its current value unless the patch explicitly answers or clears it. Missing mandatory values require an explicit answer, or an explicit clear for a supported non-yes/no type; lookup values require verified write labels. Readback checks one second after the original instant so stored yes/no values are visible.
154
154
 
155
155
  Row edits send only the changed cells to the existing recordset through the mobile endpoint. Lookup, text, numeric and date cells are supported; provider and yes/no row cells are refused before any write. Untouched cells, all other rows and row IDs must remain unchanged. DOB and household-head changes require their dedicated workflows and cannot be supplied as corrections here. An unmodified mandatory DOB is also refused because the standard batch cannot safely resend it through the DOB helper.
156
156
 
157
157
  All changes are validated before the first write. Scalar and row batches have separate journal stages, so a partial correction is not rolled back. Retry the same intent and key to reconcile; an uncertain batch is never resent automatically. `getStatus({clientId, key})` reports correction progress. Verified corrections update the original entry's verification expectations, so retrying `createEnrollment` under its old key does not restore the old path. A later correction uses a new correction key. See the [typed example](../examples/entry-correction-workflow.mts).
158
158
 
159
+ ## Resolve a rejected correction
160
+
161
+ An attempted correction stays pending until its outcome is established. A 4xx response or an unchanged read alone does not prove non-application, so the SDK does not automatically reset or resend it. New failures expose `code: 'ENTRY_CORRECTION_UNCERTAIN'`, `step`, and an available HTTP `status`. The first failure retains its original `cause`; a failed reconciliation read retains both errors in an `AggregateError`. Only allowlisted failure kind/status are persisted and returned by `getStatus`. Response bodies, messages, credentials and stacks are not written to the journal. Older journals cannot recover diagnostics they never saved.
162
+
163
+ If independent server or administrative evidence establishes that an attempted correction was not applied, call:
164
+
165
+ ```ts
166
+ await workflow.resolveEntryCorrection({
167
+ key: failedCorrectionKey,
168
+ enrollmentKey,
169
+ clientId,
170
+ evidenceReference: independentReviewReference,
171
+ confirmNotApplied: async evidence => rejectionReview.confirmNotApplied(evidence),
172
+ });
173
+ ```
174
+
175
+ `rejectionReview` is application-owned. Its callback must be read-only and confirm non-application from independent evidence; do not implement it as `() => true` or infer its answer only from HTTP status and `matchesBefore`. The callback receives identities, effective date and sanitized step diagnostics. Do not call the same client's workflow methods from the callback: the client journal lock remains held.
176
+
177
+ The SDK verifies the complete saved pre-write state before and after confirmation and rechecks enrollment context. A partial application, changed state or unavailable read prevents resolution. Successful resolution marks the correction `rejected` while retaining its original intent, attempted steps, diagnostics and evidence reference. It makes no SDK record writes to WellSky. Repeating the same resolution returns `already-rejected`; submitting that correction key again throws `ENTRY_CORRECTION_REJECTED`. Use a new correction key for the corrected intent only after resolution. A verified successful correction cannot be rejected.
178
+
179
+ This also supports pending 0.5.0 journals, including those without failure metadata, when the required independent evidence exists. A new validation failure that occurs before journaling needs no resolution.
180
+
159
181
  ## Household enrollment and interim assessments
160
182
 
161
183
  Enrollment creation and member preflight use the complete GWT list when the configured `enrollmentSession` supplies `listEnrollments`. The built-in sessions supply it. This avoids the REST `42703` failure for clients with existing household enrollments, including exited enrollments and enrollments in other programs. Reads verify the client, tenant, row identities and count; a failed GWT read never falls back to REST or means an empty list. The current GWT reader refuses lists above 1,000 records. Legacy custom readers without this method retain the counted REST path.
@@ -196,6 +218,22 @@ Client locks coordinate overlapping household and single-client operations. Dura
196
218
 
197
219
  See the typed [household example](../examples/household-enrollment-workflow.mts) and [live household verification](capabilities.md). The integration uses the configured tenant and uses an isolated authenticated page.
198
220
 
221
+ ## Public enrollment reads
222
+
223
+ The managed session exposes a GWT-backed catalog independently of the workflow:
224
+
225
+ ```ts
226
+ const {values: enrollments} =
227
+ await session.enrollmentSession.listEnrollments({clientId});
228
+
229
+ const detail = await session.enrollmentSession.readEnrollment({
230
+ clientId: headClientId, enrollmentId: headEnrollmentId,
231
+ });
232
+ // detail.members contains the complete linked clientId/enrollmentId pairs.
233
+ ```
234
+
235
+ Use the session returned by `openEnrollmentSession`. Lists include open and exited entries, validate complete counts, and refuse more than 1,000 records. The detail read currently exposes `householdClientIds`, not `householdId`; these client IDs and the enrollment's group are not interchangeable with a household ID. Household adoption still requires an explicitly verified household ID.
236
+
199
237
  ## Individual enrollment exits
200
238
 
201
239
  Use `exitEnrollment` to end a standalone person's program enrollment. It requires a verified `createEnrollment` entry in the same durable store. Supply `enrollmentSession: session.enrollmentSession` and `exitUi: createHouseholdExitUi({page: session.page})` when constructing the workflow. The same exit UI port serves individual and household forms.
@@ -226,7 +264,7 @@ Use `adoptEnrollment({key, clientId, enrollmentId})` for a standalone enrollment
226
264
 
227
265
  Adoption writes only to the local durable journal. It does not create, attach, exit or modify an HMIS record. Later entrants retain their own original entry dates. An enrollment already tracked under another entry key is rejected; use its existing key. Retry an interrupted adoption with the same inputs and store.
228
266
 
229
- After adoption, pass its key as `enrollmentKey` to `exitEnrollment` or `exitHousehold`. Exit assessment plans and normal household head rules still apply. Adoption records existing enrollment identities, not historical subassessment row-key mappings, so its returned `recordsetIds` is empty. It cannot identify rows for `correctEntry` by a new invented key. See [manual-entry exit examples](../examples/existing-enrollment-exits.mts).
267
+ After standalone adoption, its key can be used as `enrollmentKey` for `createInterim`, scalar-only `correctEntry`, or `exitEnrollment`. After household adoption, the group key works with `createHouseholdInterim` and `exitHousehold`; use the returned member's `key` for that person's scalar-only `correctEntry`. Correction still requires an open entry without reviews or exits. Do not use an all-not-applicable `createEnrollment` call merely to link an existing entry: it can create an enrollment when no match exists. Exit assessment plans and normal household head rules still apply. Adoption records existing enrollment identities, not historical subassessment row-key mappings, so its returned `recordsetIds` is empty. It cannot identify rows for `correctEntry` by a new invented key. See [manual-entry exit examples](../examples/existing-enrollment-exits.mts).
230
268
 
231
269
  ## Household exits
232
270
 
package/docs/reference.md CHANGED
@@ -36,7 +36,7 @@ Runtime exports: `AttachmentConflictError`, `CoordinatedEntryEventConflictError`
36
36
  | Profile fields and Sex | [Client profile](../browser/client-profile.d.mts) | `read`, `update` |
37
37
  | Calendar DOB | [Birth dates](../browser/date-of-birth.d.mts) | `read`, `validate`, `verify`, `update` |
38
38
  | Provider assessment defaults | [Discovery](../browser/program-discovery.d.mts) | `listAssessments`, `getProviderAssessments` |
39
- | Enrollment and household lifecycle | [Workflow](../browser/enrollment-workflow.d.mts) | Discovery, entry, `correctEntry`, `adoptEnrollment`, `adoptHouseholdEnrollment`, member additions, interim, `exitEnrollment`, `exitHousehold`, journal status; `EntryExitType` and per-type `assessmentLocations` in [enrollments](enrollments.md) |
39
+ | Enrollment and household lifecycle | [Workflow](../browser/enrollment-workflow.d.mts) | Discovery, entry, `correctEntry`, `resolveEntryCorrection`, `adoptEnrollment`, `adoptHouseholdEnrollment`, member additions, interim, `exitEnrollment`, `exitHousehold`, journal status; `EntryExitType` and per-type `assessmentLocations` in [enrollments](enrollments.md) |
40
40
  | Entry-date and exit UI ports | [Member entry](../browser/member-entry-date.d.mts), [exit](../browser/household-exit.d.mts) | Fresh reads and targeted completion |
41
41
  | Services and recovery diagnostics | [Service recording](../browser/service-recording.d.mts) | `record`, `recordBulk`, Funding Sources, `RecoveryDiagnostic` |
42
42
  | SSVF conditional rules | [SSVF types](../browser/ssvf-rules.d.mts) | [Field rules](../SSVF.md) and dated option snapshots |
@@ -28,6 +28,9 @@ Successful adoption of an existing record also needs its journal. Retain it even
28
28
  | `DefinitionDriftError`, `UnknownPicklistValueError` | Metadata or lookup assumption changed | Refresh/review definitions and application mapping. Do not guess a numeric option |
29
29
  | `AmbiguousCommitError` | Mutation outcome not established | Keep journal pending; re-read through the workflow's authoritative path |
30
30
  | `GwtEnrollmentConflictError` | Duplicate/partial/conflicting enrollment or review | Inspect membership and review instant. Do not replay the captured request |
31
+ | `UNSUPPORTED_YES_NO_CLEAR` | A correction attempted to blank a yes/no field | Fix the plan before any write; `No` and blank are different states |
32
+ | `ENTRY_CORRECTION_UNCERTAIN` | An attempted correction is not verified | Inspect `status`, `cause`, and journal step diagnostics; use [explicit resolution](enrollments.md#resolve-a-rejected-correction) only with independent non-application evidence |
33
+ | `ENTRY_CORRECTION_REJECTED` | The old correction key was resolved as not applied | Keep its audit record and use a new key for corrected intent |
31
34
  | `EnrollmentWorkflowError` | Invalid plan, missing head, schema drift, conflict, or unresolved stage | Inspect the specific reason and local status; fix application inputs only before reservation, otherwise reconcile original intent |
32
35
  | `SessionInterruptedError`, `SESSION_INTERRUPTED` | Managed page operation ended around session expiry | Check `reauthenticated`; retry the original workflow call, never its prepared RPC |
33
36
  | `LoginError`, `PASSWORD_EXPIRED` | Expiration detected with recovery disabled | Use the documented recovery option or resolve the password outside the helper |
@@ -2,6 +2,7 @@ import {
2
2
  createEnrollmentWorkflow,
3
3
  type EnrollmentWorkflowOptions,
4
4
  type EntryCorrectionInput,
5
+ type ResolveEntryCorrectionInput,
5
6
  } from '@choiceapps/communityservices-sdk/browser';
6
7
 
7
8
  /** The application supplies explicit answers, clears and original row keys.
@@ -12,3 +13,10 @@ export async function correctOriginalEntry(dependencies:EnrollmentWorkflowOption
12
13
  const workflow=createEnrollmentWorkflow(dependencies);
13
14
  return workflow.correctEntry(input);
14
15
  }
16
+
17
+ /** The confirmation callback must use independent server or administrative evidence.
18
+ * The SDK also checks the complete pre-write state before and after that callback.
19
+ * Retain the rejected key; submit a corrected request under a new key afterward. */
20
+ export async function resolveRejectedCorrection(dependencies:EnrollmentWorkflowOptions,input:ResolveEntryCorrectionInput){
21
+ return createEnrollmentWorkflow(dependencies).resolveEntryCorrection(input);
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@choiceapps/communityservices-sdk",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "SDK for WellSky Community Services (HMIS), formerly ServicePoint, Client API V2 and V3",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",