@choiceapps/communityservices-sdk 0.1.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 (294) hide show
  1. package/AGENTS.md +43 -0
  2. package/API.md +86 -0
  3. package/CHANGELOG.md +12 -0
  4. package/LICENSE +201 -0
  5. package/NOTICE +11 -0
  6. package/README.md +100 -0
  7. package/SSVF.md +131 -0
  8. package/browser/attachment-conflict.mjs +6 -0
  9. package/browser/case-plans-ui.mjs +23 -0
  10. package/browser/case-plans.d.mts +20 -0
  11. package/browser/case-plans.mjs +89 -0
  12. package/browser/client-creation.d.mts +16 -0
  13. package/browser/client-creation.mjs +60 -0
  14. package/browser/client-profile-ui.mjs +59 -0
  15. package/browser/client-profile.d.mts +24 -0
  16. package/browser/client-profile.mjs +94 -0
  17. package/browser/client-workflows.mjs +135 -0
  18. package/browser/connection.d.mts +4 -0
  19. package/browser/connection.mjs +41 -0
  20. package/browser/current-living-situation.d.mts +44 -0
  21. package/browser/current-living-situation.mjs +161 -0
  22. package/browser/date-of-birth-ui.mjs +68 -0
  23. package/browser/date-of-birth.d.mts +28 -0
  24. package/browser/date-of-birth.mjs +112 -0
  25. package/browser/enrollment-workflow.d.mts +174 -0
  26. package/browser/enrollment-workflow.mjs +518 -0
  27. package/browser/exit-details.mjs +21 -0
  28. package/browser/file-store.mjs +32 -0
  29. package/browser/gwt-mapped.mjs +46 -0
  30. package/browser/household-exit.d.mts +34 -0
  31. package/browser/household-exit.mjs +102 -0
  32. package/browser/households-ui.mjs +20 -0
  33. package/browser/households.d.mts +22 -0
  34. package/browser/households.mjs +115 -0
  35. package/browser/index.d.mts +78 -0
  36. package/browser/index.mjs +46 -0
  37. package/browser/member-entry-date.d.mts +30 -0
  38. package/browser/member-entry-date.mjs +71 -0
  39. package/browser/need-recording.d.mts +37 -0
  40. package/browser/need-recording.mjs +68 -0
  41. package/browser/need-ui.mjs +138 -0
  42. package/browser/page-lock.mjs +11 -0
  43. package/browser/password-recovery.mjs +49 -0
  44. package/browser/playwright-session.mjs +196 -0
  45. package/browser/program-discovery.d.mts +22 -0
  46. package/browser/program-discovery.mjs +80 -0
  47. package/browser/recovery-diagnostics.mjs +9 -0
  48. package/browser/service-recording.d.mts +45 -0
  49. package/browser/service-recording.mjs +148 -0
  50. package/browser/service-ui.mjs +160 -0
  51. package/browser/session-authentication.mjs +40 -0
  52. package/browser/shelter-inventory.d.mts +103 -0
  53. package/browser/shelter-inventory.mjs +273 -0
  54. package/browser/shelter-ui.mjs +382 -0
  55. package/browser/shelters.d.mts +139 -0
  56. package/browser/shelters.mjs +425 -0
  57. package/browser/ssvf-rules.d.mts +21 -0
  58. package/browser/ssvf-rules.mjs +145 -0
  59. package/browser/ui-login.mjs +135 -0
  60. package/dist/cjs/bulk.d.ts +33 -0
  61. package/dist/cjs/bulk.d.ts.map +1 -0
  62. package/dist/cjs/bulk.js +42 -0
  63. package/dist/cjs/bulk.js.map +1 -0
  64. package/dist/cjs/control.d.ts +35 -0
  65. package/dist/cjs/control.d.ts.map +1 -0
  66. package/dist/cjs/control.js +141 -0
  67. package/dist/cjs/control.js.map +1 -0
  68. package/dist/cjs/effective-date.d.ts +3 -0
  69. package/dist/cjs/effective-date.d.ts.map +1 -0
  70. package/dist/cjs/effective-date.js +27 -0
  71. package/dist/cjs/effective-date.js.map +1 -0
  72. package/dist/cjs/encoding.d.ts +32 -0
  73. package/dist/cjs/encoding.d.ts.map +1 -0
  74. package/dist/cjs/encoding.js +121 -0
  75. package/dist/cjs/encoding.js.map +1 -0
  76. package/dist/cjs/enrollment-verifications.d.ts +39 -0
  77. package/dist/cjs/enrollment-verifications.d.ts.map +1 -0
  78. package/dist/cjs/enrollment-verifications.js +96 -0
  79. package/dist/cjs/enrollment-verifications.js.map +1 -0
  80. package/dist/cjs/errors.d.ts +33 -0
  81. package/dist/cjs/errors.d.ts.map +1 -0
  82. package/dist/cjs/errors.js +45 -0
  83. package/dist/cjs/errors.js.map +1 -0
  84. package/dist/cjs/generated/v2-models.d.ts +1482 -0
  85. package/dist/cjs/generated/v2-models.d.ts.map +1 -0
  86. package/dist/cjs/generated/v2-models.js +3 -0
  87. package/dist/cjs/generated/v2-models.js.map +1 -0
  88. package/dist/cjs/generated/v2.d.ts +1149 -0
  89. package/dist/cjs/generated/v2.d.ts.map +1 -0
  90. package/dist/cjs/generated/v2.js +252 -0
  91. package/dist/cjs/generated/v2.js.map +1 -0
  92. package/dist/cjs/generated/v3-models.d.ts +1420 -0
  93. package/dist/cjs/generated/v3-models.d.ts.map +1 -0
  94. package/dist/cjs/generated/v3-models.js +3 -0
  95. package/dist/cjs/generated/v3-models.js.map +1 -0
  96. package/dist/cjs/generated/v3.d.ts +1149 -0
  97. package/dist/cjs/generated/v3.d.ts.map +1 -0
  98. package/dist/cjs/generated/v3.js +252 -0
  99. package/dist/cjs/generated/v3.js.map +1 -0
  100. package/dist/cjs/gwt-enrollments.d.ts +131 -0
  101. package/dist/cjs/gwt-enrollments.d.ts.map +1 -0
  102. package/dist/cjs/gwt-enrollments.js +280 -0
  103. package/dist/cjs/gwt-enrollments.js.map +1 -0
  104. package/dist/cjs/index.d.ts +3 -0
  105. package/dist/cjs/index.d.ts.map +1 -0
  106. package/dist/cjs/index.js +19 -0
  107. package/dist/cjs/index.js.map +1 -0
  108. package/dist/cjs/metadata.d.ts +100 -0
  109. package/dist/cjs/metadata.d.ts.map +1 -0
  110. package/dist/cjs/metadata.js +165 -0
  111. package/dist/cjs/metadata.js.map +1 -0
  112. package/dist/cjs/package.json +1 -0
  113. package/dist/cjs/program-discovery.d.ts +28 -0
  114. package/dist/cjs/program-discovery.d.ts.map +1 -0
  115. package/dist/cjs/program-discovery.js +58 -0
  116. package/dist/cjs/program-discovery.js.map +1 -0
  117. package/dist/cjs/race.d.ts +4 -0
  118. package/dist/cjs/race.d.ts.map +1 -0
  119. package/dist/cjs/race.js +40 -0
  120. package/dist/cjs/race.js.map +1 -0
  121. package/dist/cjs/runtime.d.ts +108 -0
  122. package/dist/cjs/runtime.d.ts.map +1 -0
  123. package/dist/cjs/runtime.js +38 -0
  124. package/dist/cjs/runtime.js.map +1 -0
  125. package/dist/cjs/subassessments.d.ts +29 -0
  126. package/dist/cjs/subassessments.d.ts.map +1 -0
  127. package/dist/cjs/subassessments.js +107 -0
  128. package/dist/cjs/subassessments.js.map +1 -0
  129. package/dist/cjs/testing.d.ts +45 -0
  130. package/dist/cjs/testing.d.ts.map +1 -0
  131. package/dist/cjs/testing.js +66 -0
  132. package/dist/cjs/testing.js.map +1 -0
  133. package/dist/cjs/transport.d.ts +19 -0
  134. package/dist/cjs/transport.d.ts.map +1 -0
  135. package/dist/cjs/transport.js +373 -0
  136. package/dist/cjs/transport.js.map +1 -0
  137. package/dist/cjs/v2.d.ts +13 -0
  138. package/dist/cjs/v2.d.ts.map +1 -0
  139. package/dist/cjs/v2.js +29 -0
  140. package/dist/cjs/v2.js.map +1 -0
  141. package/dist/cjs/v3.d.ts +13 -0
  142. package/dist/cjs/v3.d.ts.map +1 -0
  143. package/dist/cjs/v3.js +29 -0
  144. package/dist/cjs/v3.js.map +1 -0
  145. package/dist/cjs/validation.d.ts +33 -0
  146. package/dist/cjs/validation.d.ts.map +1 -0
  147. package/dist/cjs/validation.js +77 -0
  148. package/dist/cjs/validation.js.map +1 -0
  149. package/dist/cjs/verification.d.ts +82 -0
  150. package/dist/cjs/verification.d.ts.map +1 -0
  151. package/dist/cjs/verification.js +122 -0
  152. package/dist/cjs/verification.js.map +1 -0
  153. package/dist/esm/bulk.d.ts +33 -0
  154. package/dist/esm/bulk.d.ts.map +1 -0
  155. package/dist/esm/bulk.js +39 -0
  156. package/dist/esm/bulk.js.map +1 -0
  157. package/dist/esm/control.d.ts +35 -0
  158. package/dist/esm/control.d.ts.map +1 -0
  159. package/dist/esm/control.js +132 -0
  160. package/dist/esm/control.js.map +1 -0
  161. package/dist/esm/effective-date.d.ts +3 -0
  162. package/dist/esm/effective-date.d.ts.map +1 -0
  163. package/dist/esm/effective-date.js +24 -0
  164. package/dist/esm/effective-date.js.map +1 -0
  165. package/dist/esm/encoding.d.ts +32 -0
  166. package/dist/esm/encoding.d.ts.map +1 -0
  167. package/dist/esm/encoding.js +117 -0
  168. package/dist/esm/encoding.js.map +1 -0
  169. package/dist/esm/enrollment-verifications.d.ts +39 -0
  170. package/dist/esm/enrollment-verifications.d.ts.map +1 -0
  171. package/dist/esm/enrollment-verifications.js +93 -0
  172. package/dist/esm/enrollment-verifications.js.map +1 -0
  173. package/dist/esm/errors.d.ts +33 -0
  174. package/dist/esm/errors.d.ts.map +1 -0
  175. package/dist/esm/errors.js +33 -0
  176. package/dist/esm/errors.js.map +1 -0
  177. package/dist/esm/generated/v2-models.d.ts +1482 -0
  178. package/dist/esm/generated/v2-models.d.ts.map +1 -0
  179. package/dist/esm/generated/v2-models.js +2 -0
  180. package/dist/esm/generated/v2-models.js.map +1 -0
  181. package/dist/esm/generated/v2.d.ts +1149 -0
  182. package/dist/esm/generated/v2.d.ts.map +1 -0
  183. package/dist/esm/generated/v2.js +247 -0
  184. package/dist/esm/generated/v2.js.map +1 -0
  185. package/dist/esm/generated/v3-models.d.ts +1420 -0
  186. package/dist/esm/generated/v3-models.d.ts.map +1 -0
  187. package/dist/esm/generated/v3-models.js +2 -0
  188. package/dist/esm/generated/v3-models.js.map +1 -0
  189. package/dist/esm/generated/v3.d.ts +1149 -0
  190. package/dist/esm/generated/v3.d.ts.map +1 -0
  191. package/dist/esm/generated/v3.js +247 -0
  192. package/dist/esm/generated/v3.js.map +1 -0
  193. package/dist/esm/gwt-enrollments.d.ts +131 -0
  194. package/dist/esm/gwt-enrollments.d.ts.map +1 -0
  195. package/dist/esm/gwt-enrollments.js +274 -0
  196. package/dist/esm/gwt-enrollments.js.map +1 -0
  197. package/dist/esm/index.d.ts +3 -0
  198. package/dist/esm/index.d.ts.map +1 -0
  199. package/dist/esm/index.js +3 -0
  200. package/dist/esm/index.js.map +1 -0
  201. package/dist/esm/metadata.d.ts +100 -0
  202. package/dist/esm/metadata.d.ts.map +1 -0
  203. package/dist/esm/metadata.js +157 -0
  204. package/dist/esm/metadata.js.map +1 -0
  205. package/dist/esm/program-discovery.d.ts +28 -0
  206. package/dist/esm/program-discovery.d.ts.map +1 -0
  207. package/dist/esm/program-discovery.js +55 -0
  208. package/dist/esm/program-discovery.js.map +1 -0
  209. package/dist/esm/race.d.ts +4 -0
  210. package/dist/esm/race.d.ts.map +1 -0
  211. package/dist/esm/race.js +37 -0
  212. package/dist/esm/race.js.map +1 -0
  213. package/dist/esm/runtime.d.ts +108 -0
  214. package/dist/esm/runtime.d.ts.map +1 -0
  215. package/dist/esm/runtime.js +33 -0
  216. package/dist/esm/runtime.js.map +1 -0
  217. package/dist/esm/subassessments.d.ts +29 -0
  218. package/dist/esm/subassessments.d.ts.map +1 -0
  219. package/dist/esm/subassessments.js +103 -0
  220. package/dist/esm/subassessments.js.map +1 -0
  221. package/dist/esm/testing.d.ts +45 -0
  222. package/dist/esm/testing.d.ts.map +1 -0
  223. package/dist/esm/testing.js +60 -0
  224. package/dist/esm/testing.js.map +1 -0
  225. package/dist/esm/transport.d.ts +19 -0
  226. package/dist/esm/transport.d.ts.map +1 -0
  227. package/dist/esm/transport.js +369 -0
  228. package/dist/esm/transport.js.map +1 -0
  229. package/dist/esm/v2.d.ts +13 -0
  230. package/dist/esm/v2.d.ts.map +1 -0
  231. package/dist/esm/v2.js +13 -0
  232. package/dist/esm/v2.js.map +1 -0
  233. package/dist/esm/v3.d.ts +13 -0
  234. package/dist/esm/v3.d.ts.map +1 -0
  235. package/dist/esm/v3.js +13 -0
  236. package/dist/esm/v3.js.map +1 -0
  237. package/dist/esm/validation.d.ts +33 -0
  238. package/dist/esm/validation.d.ts.map +1 -0
  239. package/dist/esm/validation.js +72 -0
  240. package/dist/esm/validation.js.map +1 -0
  241. package/dist/esm/verification.d.ts +82 -0
  242. package/dist/esm/verification.d.ts.map +1 -0
  243. package/dist/esm/verification.js +117 -0
  244. package/dist/esm/verification.js.map +1 -0
  245. package/docs/README.md +56 -0
  246. package/docs/assessments.md +103 -0
  247. package/docs/browser-session.md +129 -0
  248. package/docs/capabilities.md +75 -0
  249. package/docs/configuration.md +81 -0
  250. package/docs/current-living-situation.md +56 -0
  251. package/docs/dates-and-answers.md +40 -0
  252. package/docs/development.md +81 -0
  253. package/docs/durable-records.md +156 -0
  254. package/docs/enrollments.md +217 -0
  255. package/docs/files-and-notes.md +39 -0
  256. package/docs/getting-started.md +56 -0
  257. package/docs/independent-needs.md +77 -0
  258. package/docs/integration-methods.md +60 -0
  259. package/docs/profiles.md +68 -0
  260. package/docs/reference.md +64 -0
  261. package/docs/rest-client.md +97 -0
  262. package/docs/services.md +66 -0
  263. package/docs/shelter-inventory.md +64 -0
  264. package/docs/shelters.md +75 -0
  265. package/docs/troubleshooting.md +61 -0
  266. package/docs/using-the-sdk.md +203 -0
  267. package/docs/vendor-issues/README.md +46 -0
  268. package/docs/vendor-issues/api-coverage.md +27 -0
  269. package/docs/vendor-issues/attachment-authentication.md +40 -0
  270. package/docs/vendor-issues/date-semantics.md +51 -0
  271. package/docs/vendor-issues/empty-service-list-404.md +51 -0
  272. package/docs/vendor-issues/enrollment-complete-target-error.md +178 -0
  273. package/docs/vendor-issues/enrollment-effective-date-column.md +112 -0
  274. package/docs/vendor-issues/household-head-validation.md +26 -0
  275. package/docs/vendor-issues/write-contracts.md +51 -0
  276. package/examples/README.md +41 -0
  277. package/examples/current-living-situation.mts +27 -0
  278. package/examples/durable-records.mts +78 -0
  279. package/examples/enrollment-workflow.mts +46 -0
  280. package/examples/gwt-enrollment-session.ts +17 -0
  281. package/examples/gwt-file-store.mjs +2 -0
  282. package/examples/household-enrollment-workflow.mts +43 -0
  283. package/examples/household-exit-workflow.mts +35 -0
  284. package/examples/household-lifecycle-workflow.mts +34 -0
  285. package/examples/independent-needs.mts +30 -0
  286. package/examples/program-discovery.ts +19 -0
  287. package/examples/quickstart.mjs +13 -0
  288. package/examples/shelter-inventory.mts +30 -0
  289. package/examples/shelters.mts +33 -0
  290. package/examples/usage.ts +23 -0
  291. package/examples/verification-snapshot.ts +9 -0
  292. package/llms.txt +26 -0
  293. package/package.json +103 -0
  294. package/specs/provenance.json +17 -0
@@ -0,0 +1,518 @@
1
+ import {apiScope} from './connection.mjs';
2
+ import {validateBirthDate} from './date-of-birth.mjs';
3
+ import {expectedExitDetails} from './exit-details.mjs';
4
+ import {createHash} from 'node:crypto';
5
+ import {readProgramCatalog,encodeAnswers,encodeNewSubassessmentRow,toItems} from '../dist/esm/index.js';
6
+
7
+ const object=v=>v!==null&&typeof v==='object'&&!Array.isArray(v);
8
+ const positive=v=>Number.isSafeInteger(v)&&v>0;
9
+ const canonical=v=>JSON.stringify(v,(_,x)=>object(x)?Object.fromEntries(Object.keys(x).sort().map(k=>[k,x[k]])):x);
10
+ const hash=v=>createHash('sha256').update(canonical(v)).digest('hex');
11
+ const fail=message=>{throw new EnrollmentWorkflowError(message);};
12
+ export class EnrollmentWorkflowError extends Error {constructor(reason){super(`Enrollment workflow: ${reason}`);this.name='EnrollmentWorkflowError';this.reason=reason;}}
13
+ function date(value){
14
+ if(typeof value!=='string'||!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.000)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)||!Number.isFinite(Date.parse(value))||new Date(value.slice(0,19)+'Z').toISOString().slice(0,19)!==value.slice(0,19))fail('timezone-qualified whole-second date required');
15
+ return new Date(value).toISOString();
16
+ }
17
+ // REST scalar/enrollment timestamps are emitted as UTC without a suffix.
18
+ const restDate=value=>typeof value==='string'?Date.parse(value.replace(' ','T').replace(/([+-]\d\d)$/,'$1:00')+(/(?:Z|[+-]\d\d(?::?\d\d)?)$/.test(value)?'':'Z')):NaN;
19
+ function key(value){if(typeof value!=='string'||!value.trim()||value.length>200)fail('stable key required');return value;}
20
+ function fields(value,allowed){if(!object(value)||Object.keys(value).some(k=>!allowed.includes(k)))fail('unknown or invalid input fields');}
21
+ function sameAnswer(expected,actual){
22
+ if(expected.answer_value==='')return actual===null||actual===undefined||actual==='';
23
+ if(expected.question_type==='date')return restDate(actual)===restDate(expected.answer_value);
24
+ if(['money','int','provider'].includes(expected.question_type))return actual!==null&&actual!==''&&Number(actual)===Number(expected.answer_value);
25
+ return String(actual)===expected.answer_value;
26
+ }
27
+ function questions(response){const rows=toItems(response);if(!Array.isArray(rows)||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 assessment read');return rows;}
28
+ function compile(definition,input){
29
+ fields(input,['answers','subassessments','endDates','notApplicable','carryForward']);
30
+ const answers=input.answers??{},excluded=input.notApplicable??{},carry=input.carryForward??[],rows=input.subassessments??[],ends=input.endDates??[];
31
+ if(!object(answers)||!object(excluded)||!Array.isArray(carry)||!Array.isArray(rows)||!Array.isArray(ends)||new Set(carry).size!==carry.length)fail('invalid assessment plan');
32
+ const scalar=encodeAnswers(definition.questions,answers,{endpoint:'mobile'}),index=new Map(definition.questions.map(q=>[q.fieldName,q]));
33
+ for(const [field,reason]of Object.entries(excluded))if(!index.has(field)||typeof reason!=='string'||!reason.trim())fail('not-applicable explanation required');
34
+ for(const field of carry)if(typeof field!=='string'||!index.has(field))fail('unknown carry-forward field');
35
+ const keys=new Set(),semantics=new Set();
36
+ const compiledRows=rows.map(row=>{
37
+ fields(row,['key','parentField','answers']);key(row.key);if(keys.has(row.key))fail('duplicate subassessment key');keys.add(row.key);
38
+ const parent=index.get(row.parentField);if(parent?.questionType!=='sub_assessment'||!object(row.answers)||Object.keys(row.answers).length===0)fail('invalid subassessment plan');
39
+ const encoded=encodeNewSubassessmentRow(parent,row.answers,{endpoint:'mobile'}).subAssessments;
40
+ for(const q of encoded)if(q.question_type==='date'&&q.answer_value)date(q.answer_value);
41
+ const normalized=encoded.map(q=>({field:q.virt_field_name,type:q.question_type,value:q.answer_value===''?'':q.question_type==='date'?restDate(q.answer_value):['money','int'].includes(q.question_type)?Number(q.answer_value):q.answer_value})).sort((a,b)=>a.field.localeCompare(b.field));
42
+ const signature=hash({parent:row.parentField,encoded:normalized});if(semantics.has(signature))fail('duplicate semantic subassessment rows');semantics.add(signature);
43
+ return {key:row.key,parentField:row.parentField,questionId:parent.questionId,encoded};
44
+ });
45
+ const endKeys=new Set();const compiledEnds=ends.map(end=>{
46
+ fields(end,['parentField','recordsetId','startDateField','endDateField','endDate']);const parent=index.get(end.parentField);
47
+ if(parent?.questionType!=='sub_assessment'||!positive(end.recordsetId)||end.startDateField===end.endDateField||!parent.children.some(q=>q.fieldName===end.startDateField&&q.questionType==='date')||!parent.children.some(q=>q.fieldName===end.endDateField&&q.questionType==='date')||endKeys.has(end.recordsetId))fail('invalid or duplicate end-date target');endKeys.add(end.recordsetId);
48
+ return {...end,endDate:date(end.endDate)};
49
+ });
50
+ const covered=new Set([...Object.keys(answers),...Object.keys(excluded),...carry,...compiledRows.map(r=>r.parentField),...compiledEnds.map(r=>r.parentField)]);
51
+ for(const q of definition.questions){
52
+ if(['blank','label'].includes(q.questionType))continue;
53
+ if(!covered.has(q.fieldName))fail(`uncovered assessment field ${q.fieldName}`);
54
+ 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');
55
+ if(q.fieldName in excluded&&carry.includes(q.fieldName))fail('conflicting field coverage');
56
+ }
57
+ const birthDate=scalar.find(q=>q.virt_field_name==='SVPPROFDOB');
58
+ if(birthDate){if(birthDate.question_type!=='date')fail('DOB question type changed');validateBirthDate(birthDate.answer_value);}
59
+ for(const q of scalar)if(q.virt_field_name!=='SVPPROFDOB'&&q.question_type==='date'&&q.answer_value)date(q.answer_value);
60
+ return {scalar:scalar.filter(q=>q.virt_field_name!=='SVPPROFDOB'),dateOfBirth:birthDate?.answer_value,rows:compiledRows,ends:compiledEnds,carry,excluded};
61
+ }
62
+ async function enrollments(api,clientId){
63
+ const all=[],ids=new Set();let count;
64
+ for(let page=1;page<=100;page++){
65
+ let result;try{result=await api.enrollments.listForClient({path:{clientId},query:{perPage:100,startPage:page,total_count:true}});}catch(e){if(page===1&&e.status===404)return [];throw e;}
66
+ if(!Number.isSafeInteger(result?.count)||result.count<0||!Array.isArray(result.values)||count!==undefined&&count!==result.count)fail('enrollment count unavailable or changed');count=result.count;
67
+ for(const row of result.values){if(!positive(row.enrollmentId)||row.clientId!==clientId||!positive(row.enrollmentProvider)||typeof row.type!=='string'||!row.type||!Number.isFinite(restDate(row.projectStartDate))||typeof row.isActive!=='boolean'||ids.has(row.enrollmentId))fail('invalid enrollment list');ids.add(row.enrollmentId);all.push(row);}
68
+ if(all.length===count)return all;if(all.length>count||!result.values.length)fail('incomplete enrollment list');
69
+ }fail('enrollment list exceeds limit');
70
+ }
71
+
72
+ /** Explicit orchestration for an existing client. All writers must share the durable store.
73
+ * No automatic retries of uncertain REST writes. This is not a cross-system transaction. */
74
+ export function createEnrollmentWorkflow({api,discovery,helpers,store,enrollmentSession,exitUi,memberEntryDatePort,birthDates}){
75
+ const tenantScope=apiScope(api, discovery, helpers, enrollmentSession, exitUi);
76
+ if(typeof discovery?.getProviderAssessments!=='function'||typeof helpers?.createInterimUpdate!=='function'||!store)fail('REST API, discovery, enrollment helpers and durable store required');
77
+ const discover=async(input,includeExit=false)=>{
78
+ fields(input,['clientId','providerId','type','entryDate']);if(!positive(input.clientId)||!positive(input.providerId)||!['HUD','VA'].includes(input.type))fail('client, provider and HUD/VA type required');const at=date(input.entryDate);
79
+ 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');
80
+ const catalog=await readProgramCatalog(api);const providers=catalog.providers.filter(p=>p.providerId===input.providerId);if(providers.length!==1)fail('active operational program required');
81
+ const configuration=await discovery.getProviderAssessments({providerId:input.providerId});if(configuration.providerId!==input.providerId)fail('provider discovery mismatch');
82
+ const prefix=input.type==='HUD'?'showHud':'showVa',resolved={};
83
+ for(const [phase,suffix]of [['entry','Entry'],['interim','Interim'],...(includeExit?[['exit','Exit']]:[])]){
84
+ const matches=configuration.defaults.filter(d=>d.location===prefix+suffix);if(matches.length!==1||matches[0].resolution!=='resolved'||!matches[0].assessment?.code)fail('program assessment default unresolved');
85
+ const assessment=matches[0].assessment;const definition=await api.assessments.getDefinition({clientId:input.clientId,assessmentCode:assessment.code,effectiveDate:at,refresh:true});
86
+ if(definition.tenant!==api.tenant||definition.assessmentCode!==assessment.code||definition.unresolvedPicklists.length)fail('assessment metadata incomplete');
87
+ resolved[phase]={assessment,definition};
88
+ }
89
+ return {provider:providers[0],...resolved};
90
+ };
91
+ const scope=clientId=>JSON.stringify([tenantScope,'enrollment-workflow',clientId]);
92
+ const reservationScope=clientId=>JSON.stringify([tenantScope,'household-workflow-reservation',clientId]);
93
+ const run=async(kind,raw,internal={})=>{
94
+ fields(raw,kind==='entry'?['key','clientId','providerId','type','entryDate','assessment']:['key','enrollmentKey','clientId',kind==='exit'?'exitDate':'reviewDate','assessment']);
95
+ const input=structuredClone(raw);key(input.key);if(!positive(input.clientId))fail('client ID required');
96
+ if(kind==='entry'){if(!positive(input.providerId)||!['HUD','VA'].includes(input.type))fail('program and HUD/VA type required');input.entryDate=date(input.entryDate);}else{key(input.enrollmentKey);if(kind==='addition')input.entryDate=date(input.entryDate);else if(kind==='exit')input.exitDate=date(input.exitDate);else input.reviewDate=date(input.reviewDate);}
97
+ if(!object(input.assessment))fail('assessment plan required');
98
+ const locked=internal.locked?async(_scope,work)=>work():store.withLock.bind(store);
99
+ return locked(scope(input.clientId),async()=>{
100
+ const reservations=await store.withLock(reservationScope(input.clientId),()=>store.read(reservationScope(input.clientId)));
101
+ if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'&&r.owner!==internal.owner))fail('unfinished household workflow must be reconciled first');
102
+ 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');
103
+ if(records.some(r=>r.stage!=='verified'&&r.key!==input.key))fail('another unfinished workflow must be reconciled first');
104
+ const matches=records.filter(r=>r.key===input.key);if(matches.length>1)fail('duplicate journal keys');let record=matches[0];
105
+ const intentHash=hash({kind,input,...(internal.owner?{household:internal.owner}:{})});if(record&&record.intentHash!==intentHash)fail('key conflicts with previous intent');
106
+ const entry=kind!=='entry'?records.find(r=>r.key===input.enrollmentKey&&r.kind==='entry'&&r.stage==='verified'):null;
107
+ if(kind!=='entry'&&!entry)fail('verified enrollment workflow required');
108
+ const context=kind==='entry'?input:kind==='addition'?{...entry.context,entryDate:input.entryDate}:entry.context;
109
+ if(kind!=='entry'&&Date.parse(input.exitDate??input.reviewDate)<Date.parse(context.entryDate))fail('review precedes entry');
110
+ const discovered=await discover({clientId:input.clientId,providerId:context.providerId,type:context.type,entryDate:kind==='entry'?input.entryDate:(input.exitDate??input.reviewDate)},kind==='exit');
111
+ const schemaHash=hash({providerId:discovered.provider.providerId,entry:{assessment:discovered.entry.assessment,questions:discovered.entry.definition.questions},interim:{assessment:discovered.interim.assessment,questions:discovered.interim.definition.questions}});
112
+ if((record&&record.schemaHash!==schemaHash)||(entry&&entry.schemaHash!==schemaHash))fail('program assessment configuration changed');
113
+ const definition=discovered[kind].definition,code=definition.assessmentCode,at=kind==='entry'?input.entryDate:(input.exitDate??input.reviewDate);
114
+ const exitSchemaHash=kind==='exit'?hash({assessment:discovered.exit.assessment,questions:definition.questions}):undefined;
115
+ if(record&&kind==='exit'&&record.exitSchemaHash!==exitSchemaHash)fail('exit assessment configuration changed');
116
+ const plan=compile(definition,input.assessment);
117
+ const carriesBirthDate=plan.carry.includes('SVPPROFDOB'),hasBirthDate=plan.dateOfBirth!==undefined||carriesBirthDate;
118
+ let carriedBirthDate;
119
+ if(hasBirthDate){
120
+ if(!birthDates||['update','read','validate','verify'].some(method=>typeof birthDates[method]!=='function'))fail('DOB helper required for birth-date answers and carry-forward');
121
+ const known=record?.steps.birthDate?.state==='verified'?record.steps.birthDate.value:undefined;
122
+ const dobContext={clientId:input.clientId,assessmentCode:code};
123
+ if(known)carriedBirthDate=await birthDates.verify({...dobContext,dateOfBirth:known.dateOfBirth,effectiveDate:known.effectiveDate,allowLater:true});
124
+ else if(plan.dateOfBirth!==undefined)await birthDates.validate({...dobContext,dateOfBirth:plan.dateOfBirth,effectiveDate:at,allowEntryCorrection:kind==='entry'});
125
+ else{
126
+ carriedBirthDate=await birthDates.read(dobContext);
127
+ if(!carriedBirthDate.dateOfBirth||!carriedBirthDate.effectiveDate||restDate(carriedBirthDate.effectiveDate)>restDate(at))fail('carry-forward birth date is empty or follows assessment date');
128
+ const expected=entry?.steps.birthDate?.value;
129
+ if(expected&&expected.dateOfBirth!==carriedBirthDate.dateOfBirth)fail('carry-forward birth date changed since enrollment');
130
+ }
131
+ }
132
+ // Enrollment head identity is fixed. A different UI/RPC target is not a new head.
133
+ const headQuestion=definition.questions.find(q=>q.fieldName==='HUD_RELATIONTOHOH'),expectedHead=internal.headClientId??input.clientId;
134
+ const checkHeadAnswer=value=>{
135
+ if(!headQuestion)return;
136
+ const self=headQuestion.options.filter(o=>o.code==='RELATIONHOH_SELF');
137
+ if(self.length!==1)fail('head of household assessment option unresolved');
138
+ if(value===null||value===undefined||value==='')fail('invalid enrollment data: head of household relationship is missing or cleared');
139
+ if((String(value)===String(self[0].id))!==(input.clientId===expectedHead))fail('head of household change is a red flag; use a separate retroactive correction for a member with the same first entry date, or exit all enrollments and create a fresh enrollment');
140
+ };
141
+ const proposedHead=plan.scalar.find(q=>q.virt_field_name==='HUD_RELATIONTOHOH');
142
+ if(proposedHead)checkHeadAnswer(proposedHead.answer_value);
143
+ if(plan.ends.length&&(kind==='entry'||plan.ends.some(e=>Date.parse(e.endDate)>=Date.parse(at)||Date.parse(e.endDate)<Date.parse(context.entryDate))))fail('row closure must fall between entry and assessment');
144
+ const read=async()=>questions(await api.assessments.get({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:at}}));
145
+ const readCurrent=async()=>questions(await api.assessments.get({path:{clientId:input.clientId,assessmentCode:code}}));
146
+ const initial=await read();
147
+ if(expectedHead===input.clientId&&headQuestion)checkHeadAnswer(proposedHead?.answer_value??initial.find(q=>q.virt_field_name==='HUD_RELATIONTOHOH')?.answer_value);
148
+ for(const field of plan.carry.filter(field=>field!=='SVPPROFDOB')){const q=initial.find(q=>q.virt_field_name===field);if(!q||(q.question_type==='sub_assessment'?!q.sub_assessment_answer_list?.length:!q.answer_value))fail('carry-forward field is empty');}
149
+ // Validate closure targets before creating enrollment/review or submitting any answers.
150
+ const current=await readCurrent();
151
+ const currentHead=current.find(q=>q.virt_field_name==='HUD_RELATIONTOHOH');
152
+ // A fresh entry may establish a different relationship from a prior enrollment.
153
+ if(kind!=='entry'&&currentHead?.answer_value!==null&&currentHead?.answer_value!==undefined&&currentHead.answer_value!=='')checkHeadAnswer(currentHead.answer_value);
154
+ for(const end of plan.ends){
155
+ const group=current.find(q=>q.virt_field_name===end.parentField),targets=group?.sub_assessment_answer_list?.filter(r=>r.recordset_id===end.recordsetId)??[];
156
+ if(targets.length!==1)fail('end-date target missing');
157
+ const children=targets[0].sub_assessment_list??[],start=children.filter(q=>q.virt_field_name===end.startDateField&&q.question_type==='date'),through=children.filter(q=>q.virt_field_name===end.endDateField&&q.question_type==='date');
158
+ if(start.length!==1||through.length!==1||!Number.isFinite(restDate(start[0].answer_value)))fail('end-date target interval missing');
159
+ if(restDate(end.endDate)<restDate(start[0].answer_value))fail('end date precedes record start');
160
+ if(through[0].answer_value&&restDate(through[0].answer_value)!==restDate(end.endDate))fail('record already has a different end date');
161
+ }
162
+ if(internal.preflight)return {assessmentCode:code,...(internal.pinSchema?{schemaHash}:{}),...(exitSchemaHash?{exitSchemaHash}:{})};
163
+ const wasVerified=record?.stage==='verified';
164
+ if(!record){record={version:1,key:input.key,kind,intentHash,schemaHash,...(exitSchemaHash?{exitSchemaHash}:{}),context:{clientId:input.clientId,providerId:context.providerId,type:context.type,entryDate:context.entryDate},assessmentCode:code,stage:'pending',steps:{},rows:{},ends:plan.ends};records.push(record);}
165
+ const persist=()=>store.write(scope(input.clientId),structuredClone(records));await persist();
166
+ const step=async(name,inspect,submit)=>{
167
+ let result=await inspect();const previous=record.steps[name];
168
+ if(result.found){record.steps[name]={state:'verified',value:result.value};await persist();return result.value;}
169
+ if(previous)fail(previous.state==='verified'?'verified step changed':'uncertain write requires reconciliation');
170
+ record.steps[name]={state:'attempted'};await persist();
171
+ try{await submit(result);}catch{/* A write response alone never establishes success or absence. */}
172
+ result=await inspect();if(!result.found)fail('uncertain write requires reconciliation');
173
+ record.steps[name]={state:'verified',value:result.value};await persist();return result.value;
174
+ };
175
+ const findEnrollment=async()=>{
176
+ const knownId=record.enrollmentId??record.steps.enrollment?.value??entry?.enrollmentId;
177
+ if(internal.findEnrollment)return internal.findEnrollment({knownId,context});
178
+ const list=await enrollments(api,input.clientId),found=list.filter(e=>e.enrollmentProvider===context.providerId&&e.type===context.type&&restDate(e.projectStartDate)===restDate(context.entryDate)&&e.isActive!==false);
179
+ if(found.length>1)fail('duplicate matching enrollments');
180
+ const id=record.enrollmentId??record.steps.enrollment?.value??entry?.enrollmentId;if(id&&(!found[0]||found[0].enrollmentId!==id))fail('saved enrollment missing or changed');
181
+ return {found:found.length===1,value:found[0]?.enrollmentId};
182
+ };
183
+ if(kind==='entry')record.enrollmentId=await step('enrollment',findEnrollment,()=>api.enrollments.createEntryAssessment({body:{clientId:input.clientId,providerId:input.providerId,type:input.type,projectStartDate:input.entryDate}}));
184
+ else {const found=await findEnrollment();if(!found.found)fail('saved enrollment missing');record.enrollmentId=entry.enrollmentId;}
185
+ await persist();
186
+ if(kind==='interim'){
187
+ // The GWT helper has its own journal and authoritative reads; it owns uncertain-write reconciliation.
188
+ 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});
189
+ 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();
190
+ }
191
+ if(kind==='exit'){
192
+ if(typeof internal.readExit!=='function')fail('household exit verification required');
193
+ const exit=await internal.readExit({clientId:input.clientId,enrollmentId:record.enrollmentId});
194
+ if(record.exitSubgroupId&&record.exitSubgroupId!==exit.exitSubgroupId)fail('exit identity changed');
195
+ record.projectExitDate=exit.projectExitDate;record.exitSubgroupId=exit.exitSubgroupId;await persist();
196
+ }
197
+ for(const end of plan.ends){
198
+ record.endSnapshots??={};
199
+ if(!record.endSnapshots[end.recordsetId]){
200
+ const source=await readCurrent(),rows=source.find(q=>q.virt_field_name===end.parentField)?.sub_assessment_answer_list;
201
+ if(!Array.isArray(rows)||new Set(rows.map(r=>r.recordset_id)).size!==rows.length)fail('ambiguous closure snapshot');
202
+ record.endSnapshots[end.recordsetId]=rows.map(r=>({recordsetId:r.recordset_id,fields:r.sub_assessment_list.filter(q=>!['label','blank'].includes(q.question_type)).map(q=>({virt_field_name:q.virt_field_name,question_type:q.question_type,answer_value:q.answer_value??''}))}));await persist();
203
+ }
204
+ await step('end:'+end.recordsetId,async()=>{
205
+ const source=await readCurrent(),rows=source.find(q=>q.virt_field_name===end.parentField)?.sub_assessment_answer_list??[];
206
+ const found=record.endSnapshots[end.recordsetId].every(before=>{
207
+ const matches=rows.filter(r=>r.recordset_id===before.recordsetId);if(matches.length!==1)return false;
208
+ return before.fields.every(q=>{
209
+ const later=records.flatMap(r=>(r.ends??[]).filter(e=>e.recordsetId===before.recordsetId&&e.endDateField===q.virt_field_name&&r.steps['end:'+e.recordsetId]?.state==='verified'));
210
+ const expected=before.recordsetId===end.recordsetId&&q.virt_field_name===end.endDateField?{...q,answer_value:end.endDate}:later.length?{...q,answer_value:later[0].endDate}:q;
211
+ const fields=matches[0].sub_assessment_list?.filter(c=>c.virt_field_name===q.virt_field_name)??[];
212
+ return fields.length===1&&fields[0].question_type===q.question_type&&sameAnswer(expected,fields[0].answer_value);
213
+ });
214
+ });return {found,value:end.recordsetId};
215
+ },()=>api.subassessments.setEndDate({clientId:input.clientId,assessmentCode:code,effectiveDate:at,parentField:end.parentField,recordsetId:end.recordsetId,endDateField:end.endDateField,endDate:end.endDate}));
216
+ }
217
+ if(plan.scalar.length)await step('scalars',async()=>{
218
+ const source=await read();return {found:plan.scalar.every(q=>{const r=source.find(r=>r.virt_field_name===q.virt_field_name);return r&&sameAnswer(q,r.answer_value)&&restDate(r.date_effective)===restDate(at);}),value:plan.scalar.map(q=>q.virt_field_name)};
219
+ },()=>api.assessmentsMobile.addAnswer({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:at},body:plan.scalar}));
220
+ // Group rows into one creation request. Partial success blocks resubmission until all rows reconcile.
221
+ if(plan.rows.length){const rowIds=await step('rows',async()=>{
222
+ const source=await readCurrent(),matched=Object.create(null),missing=[],matchedIds=new Set();
223
+ for(const row of plan.rows){
224
+ const parent=source.find(q=>q.virt_field_name===row.parentField);if(!Array.isArray(parent?.sub_assessment_answer_list))fail('subassessment read missing');
225
+ const known=record.steps.rows?.value?.[row.key];
226
+ const expected=row.encoded.map(q=>{const overrides=records.flatMap(r=>(r.ends??[]).filter(e=>e.recordsetId===known&&r.steps['end:'+e.recordsetId]?.state==='verified'&&e.endDateField===q.virt_field_name));if(overrides.length>1)fail('multiple end-date overrides');return overrides.length?{...q,answer_value:overrides[0].endDate}:q;});
227
+ const candidates=parent.sub_assessment_answer_list.filter(r=>(!known||r.recordset_id===known)&&expected.every(q=>{const fields=r.sub_assessment_list?.filter(c=>c.virt_field_name===q.virt_field_name)??[];return fields.length===1&&fields[0].question_type===q.question_type&&sameAnswer(q,fields[0].answer_value);}));
228
+ if(candidates.length>1)fail('duplicate matching subassessment rows');if(candidates.length){if(!positive(candidates[0].recordset_id)||matchedIds.has(candidates[0].recordset_id))fail('invalid or reused subassessment ID');matchedIds.add(candidates[0].recordset_id);matched[row.key]=candidates[0].recordset_id;}else missing.push(row);
229
+ }
230
+ return {found:missing.length===0,value:matched,missing};
231
+ },({missing})=>{
232
+ const grouped=new Map();for(const row of missing){let parent=grouped.get(row.parentField);if(!parent){parent={question_id:row.questionId,question_type:'sub_assessment',virt_field_name:row.parentField,answer_value:'',subAssessmentQuestionAnswers:[]};grouped.set(row.parentField,parent);}parent.subAssessmentQuestionAnswers.push({recordset_id:0,isDeleted:false,subAssessments:row.encoded});}
233
+ return api.assessmentsMobile.addAnswer({path:{clientId:input.clientId,assessmentCode:code},query:{dateEffective:at},body:[...grouped.values()]});
234
+ });record.rows=rowIds;}
235
+ // DOB owns its own durable attempt journal. Run last so later assessment writes
236
+ // do not invalidate its preservation check during recovery of an unfinished step.
237
+ if(hasBirthDate){
238
+ const dobContext={clientId:input.clientId,assessmentCode:code};
239
+ let result;
240
+ if(record.steps.birthDate?.state==='verified'){
241
+ const known=record.steps.birthDate.value;
242
+ result=await birthDates.verify({...dobContext,dateOfBirth:known.dateOfBirth,effectiveDate:known.effectiveDate,allowLater:true});
243
+ }else if(plan.dateOfBirth!==undefined){
244
+ 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}}:{})});
245
+ }else result=await birthDates.verify({...dobContext,dateOfBirth:carriedBirthDate.dateOfBirth,effectiveDate:carriedBirthDate.effectiveDate,allowLater:true});
246
+ record.steps.birthDate={state:'verified',value:{clientId:input.clientId,dateOfBirth:result.dateOfBirth,effectiveDate:result.effectiveDate}};await persist();
247
+ }
248
+ record.stage='verified';await persist();
249
+ return {key:input.key,outcome:wasVerified?'already-present':'verified',clientId:input.clientId,enrollmentId:record.enrollmentId,...(record.reviewId?{reviewId:record.reviewId}:{}),...(record.projectExitDate?{projectExitDate:record.projectExitDate,exitSubgroupId:record.exitSubgroupId}:{}),assessmentCode:code,recordsetIds:structuredClone(record.rows),effectiveDate:at,...(record.steps.birthDate?{birthDate:structuredClone(record.steps.birthDate.value)}:{})};
250
+ });
251
+ };
252
+ const householdScope=headClientId=>JSON.stringify([tenantScope,'household-enrollment-workflow',headClientId]);
253
+ const memberKey=(key,clientId)=>'household:'+hash({key,clientId});
254
+ const withClients=(ids,work,index=0)=>index===ids.length?work():store.withLock(scope(ids[index]),()=>withClients(ids,work,index+1));
255
+ const householdRun=async(kind,raw)=>{
256
+ if(typeof enrollmentSession?.readEnrollment!=='function'||typeof helpers.addHouseholdMembers!=='function'||typeof api.households?.get!=='function')fail('household API and authoritative enrollment session required');
257
+ 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']:[])]);
258
+ const input=structuredClone(raw);key(input.key);
259
+ if(kind==='exit'&&(typeof exitUi?.saveExit!=='function'||typeof exitUi?.validateExit!=='function'||typeof input.destination!=='string'||!input.destination.trim()||typeof input.reasonForLeaving!=='string'||!input.reasonForLeaving.trim()))fail('exit UI, destination and reason for leaving required');
260
+ const details=kind==='exit'?expectedExitDetails(input):null;
261
+ if(kind==='exit'&&(details.destinationDetails||details.reasonForLeavingOther)&&typeof exitUi.readExitDetails!=='function')fail('authoritative exit details reader required');
262
+ if(!positive(input.headClientId)||!Array.isArray(input.members)||input.members.length<(kind==='entry'?2:1))fail('head and household members required');
263
+ for(const member of input.members){fields(member,['clientId','assessment']);if(!positive(member.clientId)||!object(member.assessment))fail('member client and assessment required');}
264
+ input.members.sort((a,b)=>a.clientId-b.clientId);const ids=input.members.map(m=>m.clientId);
265
+ if(new Set(ids).size!==ids.length||kind==='entry'&&!ids.includes(input.headClientId))fail('unique members including head required');
266
+ if(kind==='entry'){if(!positive(input.householdId)||!positive(input.providerId)||!['HUD','VA'].includes(input.type))fail('household and program required');input.entryDate=date(input.entryDate);}
267
+ 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);}
268
+ // Read the cohort under its journal lock, then acquire all client locks in order.
269
+ // Recheck under both locks so concurrent membership growth cannot escape protection.
270
+ const prior=await store.withLock(householdScope(input.headClientId),()=>store.read(householdScope(input.headClientId)));
271
+ if(!Array.isArray(prior))fail('invalid household journal');
272
+ const lockedIds=[...new Set([input.headClientId,...ids,...prior.flatMap(r=>r.clientIds??[])])].sort((a,b)=>a-b);
273
+ return withClients(lockedIds,()=>store.withLock(householdScope(input.headClientId),async()=>{
274
+ const records=await store.read(householdScope(input.headClientId));
275
+ if(!Array.isArray(records)||records.some(r=>r.version!==1||!['entry','addition','interim','exit'].includes(r.kind)))fail('invalid household journal');
276
+ if(records.some(r=>r.clientIds.some(id=>!lockedIds.includes(id))))fail('household membership changed while acquiring locks; retry');
277
+ if(records.some(r=>r.stage!=='verified'&&r.key!==input.key))fail('another unfinished household workflow must be reconciled first');
278
+ const matches=records.filter(r=>r.key===input.key);if(matches.length>1)fail('duplicate household journal keys');let record=matches[0];
279
+ const intentHash=hash({kind,input}),owner=hash({headClientId:input.headClientId,key:input.key});
280
+ if(record&&record.intentHash!==intentHash)fail('household key conflicts with previous intent');
281
+ const entry=kind!=='entry'?records.find(r=>r.kind==='entry'&&r.key===input.enrollmentKey&&r.stage==='verified'):null;
282
+ if(kind!=='entry'&&!entry)fail('verified household entry required');
283
+ const base=entry??record;
284
+ const catalog={};
285
+ for(const group of [base,...records.filter(r=>r.kind==='addition'&&r.stage==='verified'&&r.enrollmentKey===(entry?.key??input.key))].filter(Boolean)){
286
+ for(const clientId of group.clientIds){if(catalog[clientId])fail('duplicate household member enrollment');catalog[clientId]={...group.members[clientId],entryDate:group.context.entryDate,entryKey:group.key};}
287
+ }
288
+ if(kind==='addition'){
289
+ if(typeof memberEntryDatePort?.validateMemberEntryDate!=='function'||typeof memberEntryDatePort?.saveMemberEntryDate!=='function')fail('member entry date port required');
290
+ if(ids.some(id=>catalog[id]&&catalog[id].entryKey!==input.key)||ids.includes(input.headClientId))fail('addition requires new household members');
291
+ if(restDate(input.entryDate)<=restDate(entry.context.entryDate))fail('additional member entry must follow original entry');
292
+ }else if(kind!=='entry'&&ids.some(id=>!catalog[id]))fail('verified household entry with identical members required; unknown selected member');
293
+ const cohortIds=[...new Set([...Object.keys(catalog).map(Number),...ids])].sort((a,b)=>a-b);
294
+ const context=kind==='entry'?{householdId:input.householdId,headClientId:input.headClientId,providerId:input.providerId,type:input.type,entryDate:input.entryDate}:kind==='addition'?{...entry.context,entryDate:input.entryDate}:entry.context;
295
+ if(!['entry','addition'].includes(kind)&&ids.some(id=>restDate(input.exitDate??input.reviewDate)<restDate(catalog[id].entryDate)))fail('review precedes entry');
296
+ const household=toItems(await api.households.get({path:{householdId:context.householdId}}));
297
+ if(household.length!==1||household[0].householdId!==context.householdId||household[0].isActive===false||!Array.isArray(household[0].clients))fail('active household required');
298
+ const people=household[0].clients;
299
+ if(new Set(people.map(p=>p.clientId)).size!==people.length)fail('ambiguous household membership');
300
+ const heads=people.filter(p=>p.headofhouseholdStatus&&p.isActive!==false&&!p.dateLeft);
301
+ if(heads.length===0)fail('invalid enrollment data: household has no active head of household');
302
+ if(heads.length!==1||heads[0].clientId!==input.headClientId)fail('household head mismatch; head changes are a red flag and require separate retroactive correction or a fresh enrollment');
303
+ for(const clientId of ids){const p=people.find(p=>p.clientId===clientId);if(!p||p.isActive===false||p.dateLeft||!Number.isFinite(restDate(p.dateJoined))||restDate(p.dateJoined)>restDate(catalog[clientId]?.entryDate??context.entryDate))fail('member is not active in household at entry');}
304
+ for(const clientId of cohortIds){const reservations=await store.withLock(reservationScope(clientId),()=>store.read(reservationScope(clientId)));if(!Array.isArray(reservations)||reservations.some(r=>r.stage!=='verified'&&r.owner!==owner))fail('unfinished household workflow must be reconciled first');}
305
+ const validateSnapshot=(snapshot,target,{partial=false}={})=>{
306
+ if(snapshot?.source!=='gwt-rpc'||snapshot.tenant!==api.tenant||snapshot.clientId!==target.clientId||snapshot.enrollmentId!==target.enrollmentId||snapshot.providerId!==context.providerId||snapshot.enrollmentType!==context.type||restDate(snapshot.projectStartDate)!==restDate(catalog[target.clientId]?.entryDate??context.entryDate))fail('household enrollment context mismatch');
307
+ const members=snapshot.members;
308
+ if(Array.isArray(members)&&!members.some(m=>m.clientId===input.headClientId))fail('invalid enrollment data: enrollment has no head of household');
309
+ if(!Array.isArray(members)||members.some(m=>!positive(m.clientId)||!positive(m.enrollmentId))||new Set(members.map(m=>m.clientId)).size!==members.length||new Set(members.map(m=>m.enrollmentId)).size!==members.length||!members.some(m=>m.clientId===target.clientId&&m.enrollmentId===target.enrollmentId))fail('invalid household enrollment members');
310
+ const actual=members.map(m=>m.clientId).sort((a,b)=>a-b);
311
+ if(partial?actual.some(id=>!cohortIds.includes(id)):canonical(actual)!==canonical(cohortIds))fail('household enrollment members changed');
312
+ if(!Array.isArray(snapshot.householdClientIds)||cohortIds.some(id=>!snapshot.householdClientIds.includes(id)))fail('household members unavailable');
313
+ for(const m of members){const known=record?.members[m.clientId]?.enrollmentId??catalog[m.clientId]?.enrollmentId;if(known&&known!==m.enrollmentId)fail('household enrollment identity changed');}
314
+ return snapshot;
315
+ };
316
+ const readTarget=async(target,options)=>validateSnapshot(await enrollmentSession.readEnrollment(target),target,options);
317
+ // The original head must cover every member's enrollment interval. A household
318
+ // designation alone is insufficient once that person's program enrollment ends.
319
+ const validateHeadCoverage=async(projected=false)=>{
320
+ const enrollmentId=catalog[input.headClientId]?.enrollmentId??record?.members[input.headClientId]?.enrollmentId;
321
+ if(!enrollmentId){if(kind==='entry')return;fail('invalid enrollment data: enrollment has no head of household');}
322
+ const snapshot=await readTarget({clientId:input.headClientId,enrollmentId},{partial:!record?.attached&&(kind==='entry'||kind==='addition')});
323
+ if(kind!=='entry'&&Object.keys(catalog).some(id=>!snapshot.members.some(m=>m.clientId===Number(id))))fail('household enrollment members changed');
324
+ const intervals=snapshot.members.map(member=>{
325
+ const exits=snapshot.exits?.filter(e=>e.clientId===member.clientId&&e.enrollmentId===member.enrollmentId);
326
+ if(exits?.length!==1||exits[0].projectExitDate!==null&&!Number.isFinite(restDate(exits[0].projectExitDate)))fail('authoritative head of household exit dates required');
327
+ return {clientId:member.clientId,start:restDate(catalog[member.clientId]?.entryDate??context.entryDate),end:exits[0].projectExitDate===null?Infinity:restDate(exits[0].projectExitDate)};
328
+ });
329
+ if(projected&&kind==='exit')for(const member of intervals)if(ids.includes(member.clientId))member.end=restDate(input.exitDate);
330
+ if(projected&&kind==='addition')for(const clientId of ids)if(!intervals.some(m=>m.clientId===clientId))intervals.push({clientId,start:restDate(input.entryDate),end:Infinity});
331
+ const head=intervals.find(m=>m.clientId===input.headClientId);
332
+ if(intervals.some(m=>m.start<head.start||m.end>head.end))fail('invalid enrollment data: member enrollment would exist without an enrolled head of household; the original head must remain enrolled through every member exit; reconcile the data or exit all members and create a fresh enrollment');
333
+ };
334
+ await validateHeadCoverage();
335
+ await validateHeadCoverage(true);
336
+ 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};
337
+ // Validate every member before any record mutation. The same client locks cover
338
+ // preflight and execution, including overlaps with the single-client workflow.
339
+ 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}:{})}));
340
+ if(kind==='addition'&&record?.entrySchemas&&canonical(record.entrySchemas)!==canonical(preflight))fail('additional member assessment configuration changed');
341
+ if(kind==='exit'&&record?.exitSchemas&&canonical(record.exitSchemas)!==canonical(preflight))fail('exit assessment configuration changed');
342
+ const wasVerified=record?.stage==='verified';
343
+ if(!record){record={version:1,key:input.key,kind,intentHash,context,...(input.enrollmentKey?{enrollmentKey:input.enrollmentKey}:{}),clientIds:ids,stage:'pending',members:{},...(kind==='exit'?{exitSchemas:preflight}:kind==='addition'?{entrySchemas:preflight}:{})};records.push(record);}
344
+ const persist=()=>store.write(householdScope(input.headClientId),structuredClone(records));await persist();
345
+ for(const clientId of cohortIds)await store.withLock(reservationScope(clientId),()=>store.write(reservationScope(clientId),[{owner,stage:'pending'}]));
346
+ const findMember=clientId=>async({knownId})=>{
347
+ const expected=record.members[clientId]?.enrollmentId??catalog[clientId]?.enrollmentId??knownId;
348
+ if(expected){if(knownId&&knownId!==expected)fail('household enrollment identity changed');await readTarget({clientId,enrollmentId:expected},{partial:kind==='entry'&&clientId===input.headClientId&&!record.attached});return {found:true,value:expected};}
349
+ if(clientId!==input.headClientId)fail('member enrollment has not been attached');
350
+ const found=(await enrollments(api,clientId)).filter(e=>e.enrollmentProvider===context.providerId&&e.type===context.type&&e.isActive&&restDate(e.projectStartDate)===restDate(context.entryDate));
351
+ if(found.length>1)fail('duplicate matching enrollments');
352
+ if(found[0])await readTarget({clientId,enrollmentId:found[0].enrollmentId},{partial:!record.attached});
353
+ return {found:found.length===1,value:found[0]?.enrollmentId};
354
+ };
355
+ const readPreserved=async()=>{
356
+ const result=[];
357
+ for(const clientId of cohortIds.filter(id=>!ids.includes(id))){
358
+ const enrollmentId=catalog[clientId].enrollmentId,snapshot=await readTarget({clientId,enrollmentId},{partial:kind==='addition'&&!record.attached});
359
+ const exits=snapshot.exits?.filter(e=>e.clientId===clientId&&e.enrollmentId===enrollmentId);if(exits?.length!==1)fail('unselected member exit identity missing');
360
+ const exit=exits[0],details=exit.projectExitDate&&typeof exitUi?.readExitDetails==='function'?await exitUi.readExitDetails({clientId,enrollmentId}):null;
361
+ result.push({clientId,enrollmentId,entryDate:snapshot.projectStartDate,exit,details,reviews:snapshot.reviews});
362
+ }return result;
363
+ };
364
+ const preserve=async()=>{
365
+ if(wasVerified)return;
366
+ const observed=await readPreserved();if(record.preserved&&canonical(record.preserved)!==canonical(observed))fail('unselected household member changed');
367
+ if(!record.preserved){record.preserved=observed;await persist();}
368
+ };
369
+ if(kind==='exit'||kind==='addition')await preserve();
370
+ if(kind==='entry'){
371
+ // Before attachment, an independently matching member enrollment is ambiguous.
372
+ // Once any attachment was attempted, only the GWT journal can reconcile it.
373
+ if(!record.attachmentAttempted)for(const clientId of ids.filter(id=>id!==input.headClientId)){
374
+ const existing=await enrollments(api,clientId);if(existing.some(e=>e.enrollmentProvider===context.providerId&&e.type===context.type&&e.isActive&&restDate(e.projectStartDate)===restDate(context.entryDate)))fail('member already has an independent matching enrollment');
375
+ }
376
+ const head=input.members.find(m=>m.clientId===input.headClientId);
377
+ const result=await run('entry',toMember(head),{locked:true,owner,headClientId:input.headClientId,findEnrollment:findMember(head.clientId)});record.members[head.clientId]=result;await persist();
378
+ // Reject unexpected members before the attachment helper is allowed to write.
379
+ await readTarget({clientId:head.clientId,enrollmentId:result.enrollmentId},{partial:!record.attached});
380
+ record.attachmentAttempted=true;await persist();
381
+ const attached=await helpers.addHouseholdMembers({clientId:head.clientId,enrollmentId:result.enrollmentId,memberClientIds:ids.filter(id=>id!==head.clientId)});
382
+ const snapshot=validateSnapshot(attached.snapshot,{clientId:head.clientId,enrollmentId:result.enrollmentId});
383
+ for(const m of snapshot.members)record.members[m.clientId]??={clientId:m.clientId,enrollmentId:m.enrollmentId};record.attached=true;await persist();
384
+ 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();}
385
+ }else if(kind==='addition'){
386
+ if(!record.anchor){
387
+ for(const clientId of Object.keys(catalog).map(Number).sort((a,b)=>a-b)){
388
+ const enrollmentId=catalog[clientId].enrollmentId,snapshot=await readTarget({clientId,enrollmentId},{partial:true});
389
+ const exit=snapshot.exits?.find(e=>e.clientId===clientId&&e.enrollmentId===enrollmentId);
390
+ if(exit?.projectExitDate===null&&restDate(snapshot.projectStartDate)<=restDate(input.entryDate)){record.anchor={clientId,enrollmentId};break;}
391
+ }
392
+ if(!record.anchor)fail('open household enrollment required to add a member');await persist();
393
+ }
394
+ if(!record.attachmentAttempted)for(const clientId of ids){
395
+ const existing=await enrollments(api,clientId);if(existing.some(e=>e.enrollmentProvider===context.providerId&&e.type===context.type&&e.isActive))fail('additional member already has an independent enrollment');
396
+ }
397
+ record.attachmentAttempted=true;await persist();
398
+ const attached=await helpers.addHouseholdMembers({...record.anchor,memberClientIds:ids});
399
+ validateSnapshot(attached.snapshot,record.anchor);
400
+ 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};}
401
+ record.attached=true;await persist();await preserve();
402
+ record.entryDates??={};record.entryDateInputs??={};
403
+ for(const clientId of ids){
404
+ const target={clientId,enrollmentId:record.members[clientId].enrollmentId},snapshot=await enrollmentSession.readEnrollment(target);
405
+ if(restDate(snapshot.projectStartDate)!==restDate(input.entryDate)){
406
+ if(record.entryDates[clientId])fail('uncertain member entry date requires reconciliation');
407
+ const cohort=[];
408
+ for(const id of cohortIds){
409
+ const enrollmentId=catalog[id]?.enrollmentId??record.members[id].enrollmentId,current=await enrollmentSession.readEnrollment({clientId:id,enrollmentId});
410
+ const matching=current.exits?.filter(e=>e.clientId===id&&e.enrollmentId===enrollmentId);if(matching?.length!==1)fail('member exit identity missing');
411
+ cohort.push({clientId:id,enrollmentId,entryDate:current.projectStartDate,exit:matching[0]});
412
+ }
413
+ const change={...target,providerId:context.providerId,type:context.type,entryDate:input.entryDate,cohort};
414
+ await memberEntryDatePort.validateMemberEntryDate(change);
415
+ record.entryDates[clientId]='attempted';record.entryDateInputs[clientId]=change;await persist();
416
+ try{await memberEntryDatePort.saveMemberEntryDate(change);}catch{/* Resolve only through readback; never replay the date update. */}
417
+ }
418
+ if(record.entryDates[clientId]==='attempted'&&record.entryDateInputs[clientId])await memberEntryDatePort.validateMemberEntryDate(record.entryDateInputs[clientId]);
419
+ 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');
420
+ await preserve();record.entryDates[clientId]='verified';await persist();
421
+ }
422
+ 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();}
423
+ }else if(kind==='interim'){
424
+ // Verify all linked enrollments before making the single household review call.
425
+ const existingReviews=[];
426
+ for(const clientId of ids){
427
+ const snapshot=await readTarget({clientId,enrollmentId:catalog[clientId].enrollmentId});
428
+ if(!Array.isArray(snapshot.reviews))fail('household reviews unavailable');
429
+ if(snapshot.exits?.some(e=>e.clientId===clientId&&e.projectExitDate&&restDate(e.projectExitDate)<restDate(input.reviewDate)))fail('review follows project exit');
430
+ const matching=snapshot.reviews.filter(r=>r.type==='Update'&&restDate(r.reviewDate)===restDate(input.reviewDate));
431
+ if(matching.length>1||matching.some(r=>!positive(r.reviewId)||!Array.isArray(r.clientIds)||canonical([...r.clientIds].sort((a,b)=>a-b))!==canonical(ids)))fail('existing household review conflict');
432
+ existingReviews.push(matching[0]);
433
+ }
434
+ if(existingReviews.some(Boolean)&&existingReviews.some(r=>!r))fail('partial household review requires reconciliation');
435
+ if(existingReviews[0]&&new Set(existingReviews.map(r=>r.reviewId)).size!==ids.length)fail('duplicate household review IDs');
436
+ const target={clientId:ids[0],enrollmentId:catalog[ids[0]].enrollmentId};
437
+ const review=await helpers.createInterimUpdate({...target,clientIds:ids,reviewDate:input.reviewDate});validateSnapshot(review.snapshot,target);
438
+ const readReview=async target=>{
439
+ const snapshot=await readTarget(target),reviews=snapshot.reviews.filter(r=>r.type==='Update'&&restDate(r.reviewDate)===restDate(input.reviewDate));
440
+ if(reviews.length!==1||!positive(reviews[0].reviewId)||canonical([...reviews[0].clientIds].sort((a,b)=>a-b))!==canonical(ids))fail('household review membership mismatch');
441
+ const known=record.members[target.clientId]?.reviewId;if(known&&known!==reviews[0].reviewId)fail('household review identity changed');
442
+ return {snapshot};
443
+ };
444
+ // Pin every member's distinct review ID before writing any member's answers.
445
+ 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();}
446
+ if(new Set(ids.map(id=>record.members[id].reviewId)).size!==ids.length)fail('duplicate household review IDs');
447
+ 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();}
448
+ }else if(kind==='exit'){
449
+ const target={clientId:ids[0],enrollmentId:catalog[ids[0]].enrollmentId};
450
+ const normalizeLabel=value=>typeof value==='string'?value.replace(/\s*\(HUD\)\s*$/,'').trim():value;
451
+ const inspect=async()=>{
452
+ const exits=[];
453
+ for(const clientId of ids){
454
+ const enrollmentId=catalog[clientId].enrollmentId,snapshot=await readTarget({clientId,enrollmentId});
455
+ if(!Array.isArray(snapshot.reviews)||snapshot.reviews.some(r=>!Number.isFinite(restDate(r.reviewDate))||restDate(r.reviewDate)>restDate(input.exitDate)))fail('exit precedes an existing review');
456
+ if(!Array.isArray(snapshot.exits))fail('authoritative exit read required');
457
+ const matches=snapshot.exits.filter(e=>e.clientId===clientId&&e.enrollmentId===enrollmentId);if(matches.length!==1)fail('exit identity missing');
458
+ const exit=matches[0];
459
+ if(exit.projectExitDate!==null){
460
+ if(restDate(exit.projectExitDate)!==restDate(input.exitDate)||normalizeLabel(exit.destination)!==normalizeLabel(input.destination)||normalizeLabel(exit.reasonForLeaving)!==normalizeLabel(input.reasonForLeaving)||exit.exitSubgroupId===null||exit.exitSubgroupId===undefined)fail('existing exit conflicts with requested exit');
461
+ if(typeof exitUi.readExitDetails==='function'&&canonical(await exitUi.readExitDetails({clientId,enrollmentId}))!==canonical(details))fail('stored exit details conflict with requested exit');
462
+ if(!Array.isArray(exit.clientIds)||!Array.isArray(exit.enrollmentIds)||canonical([...exit.clientIds].sort((a,b)=>a-b))!==canonical(ids)||canonical([...exit.enrollmentIds].sort((a,b)=>a-b))!==canonical(ids.map(id=>catalog[id].enrollmentId).sort((a,b)=>a-b)))fail('exit subgroup membership mismatch');
463
+ if(record.exitSubgroupId!==undefined&&record.exitSubgroupId!==exit.exitSubgroupId)fail('exit subgroup identity changed');
464
+ }else if([exit.destination,exit.reasonForLeaving,exit.exitSubgroupId].some(v=>v!==null))fail('incomplete existing exit metadata');
465
+ exits.push(exit);
466
+ }
467
+ const saved=exits.filter(e=>e.projectExitDate!==null);
468
+ if(saved.length&&new Set(saved.map(e=>e.exitSubgroupId)).size!==1)fail('household exits are not linked');
469
+ return {found:saved.length===ids.length,partial:saved.length>0,exits};
470
+ };
471
+ let observed=await inspect();
472
+ if(!observed.found){
473
+ if(record.exitStep)fail(record.exitStep==='verified'?'verified exit missing':'uncertain household exit requires reconciliation');
474
+ if(observed.partial)fail('partial existing household exit requires reconciliation');
475
+ const exitInput={...target,clientIds:ids,exitDate:input.exitDate,destination:input.destination,reasonForLeaving:input.reasonForLeaving,...(input.destinationDetails?{destinationDetails:input.destinationDetails}:{}),...(input.reasonForLeavingOther?{reasonForLeavingOther:input.reasonForLeavingOther}:{})};
476
+ await exitUi.validateExit(exitInput);
477
+ record.exitStep='attempted';await persist();
478
+ try{await exitUi.saveExit(exitInput);}catch{/* Only authoritative readback establishes success. Never replay an uncertain save. */}
479
+ observed=await inspect();if(!observed.found)fail('uncertain household exit requires reconciliation');
480
+ }
481
+ await validateHeadCoverage();await preserve();
482
+ record.exitStep='verified';record.exitSubgroupId=observed.exits[0].exitSubgroupId;await persist();
483
+ const readExit=async target=>{
484
+ const result=await inspect();if(!result.found)fail('verified exit missing');return result.exits.find(e=>e.clientId===target.clientId&&e.enrollmentId===target.enrollmentId);
485
+ };
486
+ 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();}
487
+ }
488
+ if(kind==='exit'||kind==='addition')await preserve();
489
+ await validateHeadCoverage();
490
+ record.stage='verified';await persist();for(const clientId of cohortIds)await store.withLock(reservationScope(clientId),()=>store.write(reservationScope(clientId),[{owner,stage:'verified'}]));
491
+ return {key:input.key,outcome:wasVerified?'already-present':'verified',householdId:context.householdId,headClientId:input.headClientId,members:ids.map(id=>structuredClone(record.members[id]))};
492
+ }));
493
+ };
494
+ return {
495
+ addHouseholdMembers:input=>householdRun('addition',input),
496
+ createHouseholdEnrollment:input=>householdRun('entry',input),createHouseholdInterim:input=>householdRun('interim',input),exitHousehold:input=>householdRun('exit',input),
497
+ discoverExit:input=>discover(input,true),
498
+ async getHouseholdStatus(input){
499
+ fields(input,['headClientId','key']);if(!positive(input.headClientId))fail('head client ID required');key(input.key);
500
+ return store.withLock(householdScope(input.headClientId),async()=>{
501
+ const records=await store.read(householdScope(input.headClientId));if(!Array.isArray(records))fail('invalid household journal');
502
+ const matches=records.filter(r=>r.key===input.key);if(matches.length>1)fail('duplicate household journal keys');const record=matches[0];if(!record)return null;
503
+ if(record.version!==1||!object(record.members))fail('invalid household journal');
504
+ 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]??{})}))};
505
+ });
506
+ },
507
+ discover,createEnrollment:input=>run('entry',input),createInterim:input=>run('interim',input),
508
+ async getStatus(input){
509
+ fields(input,['clientId','key']);if(!positive(input.clientId))fail('client ID required');key(input.key);
510
+ return store.withLock(scope(input.clientId),async()=>{
511
+ const records=await store.read(scope(input.clientId));if(!Array.isArray(records))fail('invalid workflow journal');
512
+ 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;
513
+ if(r.version!==1||!object(r.steps))fail('invalid workflow journal');
514
+ 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}))};
515
+ });
516
+ },
517
+ };
518
+ }
@@ -0,0 +1,21 @@
1
+ const normalize=value=>typeof value==='string'?value.replace(/\s*\(HUD\)\s*$/,'').trim():value;
2
+ export const rentalDestination='Rental by client, with ongoing housing subsidy';
3
+ const text=(value,max)=>typeof value==='string'&&value.trim()===value&&value.length>0&&value.length<=max&&!/[\x00-\x1f\x7f-\x9f]/.test(value);
4
+ /** Validate only explicit caller intent. Keep omission stable for existing journals. */
5
+ export function expectedExitDetails(input){
6
+ const details=input.destinationDetails,reason=input.reasonForLeavingOther;
7
+ if(details!==undefined){
8
+ if(!details||typeof details!=='object'||Array.isArray(details))throw Error('Invalid destination details');
9
+ const keys=details.kind==='rental-subsidy'?['kind','rentalSubsidyType']:details.kind==='other'?['kind','explanation']:[];
10
+ if(!keys.length||Object.keys(details).some(k=>!keys.includes(k)))throw Error('Invalid destination details');
11
+ }
12
+ if(normalize(input.destination)===rentalDestination){
13
+ if(details?.kind!=='rental-subsidy'||!text(details.rentalSubsidyType,500))throw Error('Rental subsidy type required');
14
+ }else if(normalize(input.destination)==='Other'){
15
+ if(details?.kind!=='other'||!text(details.explanation,100))throw Error('Destination Other explanation required, maximum 100 characters');
16
+ }else if(details!==undefined)throw Error('Destination details do not apply');
17
+ if(normalize(input.reasonForLeaving)==='Other'){
18
+ if(!text(reason,100))throw Error('Reason for leaving Other explanation required, maximum 100 characters');
19
+ }else if(reason!==undefined)throw Error('Reason for leaving explanation does not apply');
20
+ return {destinationDetails:details??null,reasonForLeavingOther:reason??null};
21
+ }
@@ -0,0 +1,32 @@
1
+ // Local single-host durable journal. Keep its data directory private and outside Git.
2
+ // A crash leaves a lock intentionally. Inspect/reconcile it before manual removal; never expire locks automatically.
3
+ import {mkdir,open,readFile,rename,unlink} from 'node:fs/promises';
4
+ import {createHash,randomUUID} from 'node:crypto';
5
+ import {join,resolve} from 'node:path';
6
+
7
+ export function createFileGwtMutationStore(directory) {
8
+ const root=resolve(directory),held=new Map();
9
+ const name=scope=>createHash('sha256').update(scope).digest('hex');
10
+ const owned=scope=>{const token=held.get(scope);if(!token)throw Error('Journal requires its enrollment lock');return token;};
11
+ return {
12
+ async withLock(scope,work) {
13
+ await mkdir(root,{recursive:true,mode:0o700});
14
+ const lockPath=join(root,`${name(scope)}.lock`),token=randomUUID();
15
+ const lock=await open(lockPath,'wx',0o600); // EEXIST fails closed for concurrent writers or an unresolved crash.
16
+ try {await lock.writeFile(token);await lock.sync();held.set(scope,token);return await work();}
17
+ finally {held.delete(scope);await lock.close();if(await readFile(lockPath,'utf8')===token)await unlink(lockPath);}
18
+ },
19
+ async read(scope) {
20
+ owned(scope);
21
+ try {return JSON.parse(await readFile(join(root,`${name(scope)}.json`),'utf8'));}
22
+ catch(error){if(error?.code==='ENOENT')return [];throw error;}
23
+ },
24
+ async write(scope,attempts) {
25
+ const token=owned(scope),destination=join(root,`${name(scope)}.json`),temporary=destination+`.${token}.tmp`;
26
+ const file=await open(temporary,'wx',0o600);
27
+ try {await file.writeFile(JSON.stringify(attempts));await file.sync();} finally {await file.close();}
28
+ await rename(temporary,destination);
29
+ const dir=await open(root,'r');try {await dir.sync();}finally{await dir.close();}
30
+ },
31
+ };
32
+ }