You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The eligibility domain's rules engine adapter needs a structured view of household member data to evaluate SNAP and Medicaid eligibility. No such endpoint exists today. Issue #354 proposed a bespoke hand-crafted endpoint (EligibilitySnapshot with PUT upsert semantics); that PR was closed without merging. This issue replaces that approach: the eligibility context should be expressed as a plain declarative composition (using the infrastructure from #357) that assembles member-level data from multiple intake sub-resources into a single GET endpoint. This issue produces updates to the intake architecture doc and the composition cross-cutting doc.
Reference branch for baseline design thinking:origin/feat/eligibility-snapshot (the unmerged #354 branch). The docs on that branch (intake.md, eligibility.md, inter-domain-communication.md) represent the latest thinking even though the implementation was not merged. The decisions below record where we have departed from that baseline and why.
Settled decisions
GET-based live assembly, not PUT-based materialization
Decision: The composition endpoint is a standard GET. The Eligibility state machine calls GET /intake/applications/{id}/eligibility-context before each evaluate call; the composition runtime assembles current data from Intake's sub-resources and returns it. No stored EligibilitySnapshot resource in Intake; no PUT upsert.
Rationale: The #354 design used PUT upsert so that PII would stay in Intake's storage boundary (the snapshot is assembled and returned but not persisted in Eligibility). That constraint does not hold: the "determination context" that the rules engine uses must be stored with the Determination for audit and appeal purposes — you cannot satisfy federal audit requirements without a durable record of the inputs used to reach an outcome. The PUT approach also didn't eliminate the runtime dependency on Intake (the state machine still calls PUT synchronously before evaluate); it just shifted when the call happens. GET-based live assembly is architecturally equivalent at evaluate time and fits the composition infrastructure directly without extension.
PII flows to Eligibility's Decision records
Decision: The GET response is stored as Decision.memberContext and Determination.householdContext on Eligibility's records. This is the "determination context" pattern: the domain that owns the outcome owns the inputs that produced it.
Rationale: IBM Cúram's determination context, Salesforce Government Cloud's evaluation objects, and CalSAWS all store input snapshots with the determination record — not in the source domain. The #354 approach (audit trail via event log replay of EligibilitySnapshot.updated events) is operationally fragile: it requires the event log to be queryable, replayable, and long-lived, which varies significantly across state deployments. Stored fields on Determination/Decision are durable, queryable, and portable. States with strict FTI controls (IRS Pub. 1075) may need to overlay the eligibility context to limit which income fields are persisted on Decision.memberContext — that is an overlay concern, not a baseline design constraint.
Assembly logic stays in Intake
Decision: The composition config lives in Intake (the owning domain). Eligibility calls one endpoint and receives a pre-built payload without knowing which sub-resources contribute or how they are joined.
Rationale: Intake holds the authoritative application data and knows its own internal resource structure. If assembly logic lived in Eligibility's state machine or in the adapter, every Intake model change would require an Eligibility change. The composition config encodes the assembly contract in a single place, overlay-extensible by states.
Availability dependency is accepted
Decision: The GET call to Intake at evaluate time is an accepted synchronous dependency on a co-deployed service. No fallback to cached data is built into the baseline.
Rationale: The PUT upsert approach did not eliminate this dependency — the state machine still called PUT synchronously before evaluate. The only way to eliminate the dependency is event-driven push (Intake publishes updates when data changes; Eligibility maintains a continuously-synchronized projection) — that is significantly more complex and no major platform does it for eligibility evaluation. In practice, Intake and Eligibility are co-deployed; downtime is correlated. Post-evaluation access (audit, appeal, supervisor review) uses the stored Decision.memberContext and does not require Intake to be available.
Application-scoped endpoint with query param filtering
Decision: One composition endpoint per application (GET /intake/applications/{id}/eligibility-context). Callers that need a single member's data pass ?memberId=abc.
Rationale: SNAP evaluates the household as a unit — it needs all members in one call. Medicaid ex parte evaluates per person — callers can filter to a single member via query param. A separate per-member endpoint would require the rules engine adapter to know which endpoint to call per program type; that routing logic belongs in the composition config, not spread across callers. The plain composition filtering gap (see Required fixes) enables ?memberId=abc to work once fixed.
Resource structure mirrors the eligibility input data model
Each logical grouping of eligibility input data maps to exactly one Intake resource. The structure follows the rules engine's input model directly: root-level properties extend existing resources; named sub-objects become new singleton resources; multi-valued arrays are existing or new collection resources. Resource names match the corresponding input model category names.
New member-scoped resources (one record per member, bound by applicationId + memberId):
Sponsorship belongs with immigration details, not as a standalone resource
Decision: Sponsor data is a sub-record on member-immigration-details, not a separate resource.
Rationale: Sponsorship is only relevant for non-citizen members and is inherently tied to immigration status. The rules engine treats sponsor data as a nested object within immigration details. A standalone sponsorship resource would require a join back to the member's immigration record to be meaningful — unnecessary indirection.
No backing state resource needed
Decision: The eligibility context does not need a backing state resource at the baseline.
Rationale: Post-evaluation access uses the stored Decision.memberContext. Whether data has changed since the last rules engine run is tracked via the Determination's evaluation timestamps on the Eligibility side, not via a state resource in Intake. States that need change-detection can overlay a state resource.
Eligibility context fields are opaque objects, not schema mirrors
Decision:Decision.memberContext and Determination.householdContext are typed as type: object with additionalProperties: true — opaque blobs. They are not given schemas that mirror the Intake composition response shape.
Rationale: These fields are audit snapshots — they record whatever the rules engine received at evaluation time, not a live view of Intake data. The exact shape is defined by the rules engine's input model, which is external to the blueprint and varies by state. Duplicating the Intake composition shape in Eligibility's OpenAPI spec would create a maintenance coupling: every change to the composition would require an Eligibility schema update. Salesforce Government Cloud's evaluation objects and IBM Cúram's determination context both store the input payload as a flexible structure, not a typed mirror of the source domain's schema.
Update events suffice for progressive evaluation audit
Decision: Storing memberContext as a single overwritable field on Decision is sufficient for progressive determination. No versioned history or array of snapshots is needed on the record itself.
Rationale: The platform emits ResourceUpdatedEvent automatically on every PATCH, carrying before/after values for all changed fields. Each evaluate pass that writes a new value to memberContext produces a decision.updated event with {field: "memberContext", before: {...}, after: {...}}. The event log preserves the complete evaluation history. The current field value reflects the latest evaluation state; prior states are recoverable from the log. This is equivalent in durability to Cúram's determination context pattern and does not require any additional audit infrastructure.
New resource schemas defined in schemas/common/, not inline in OpenAPI
Decision: New Intake resource schemas introduced by this issue (MemberPregnancy, MemberVeteranStatus, etc.) are defined as JSON Schema objects in schemas/common/intake.yaml. intake-openapi.yaml and state machine files reference them via $ref: ./schemas/common/intake.yaml#/$defs/SchemaName. They are not defined inline in the OpenAPI spec.
Rationale: Resource schemas must be usable by both the OpenAPI spec and state machine transition request/response validation. Inline OpenAPI schemas are not accessible to state machines. The schemas/common/ pattern is already established for shared enums and types (enums.yaml, member.yaml, household.yaml, etc.), and the meta-schema already uses external $ref to schemas/common/enums.yaml. Extending this pattern to resource schemas avoids duplication between the API contract and the state machine validation layer.
Two distinct field path formats: schema key and JSONPath
Decision: The blueprint uses two distinct field path formats for different purposes. They must not be mixed.
Schema key (SchemaName.fieldName, e.g., MemberPregnancy.isPregnant) — identifies a field by its location in a schema. Used for annotation keys, sort/search nested field params, and any context where a field is named by schema location rather than navigated to through data. No array traversal notation.
JSONPath (e.g., members[].pregnancy.isPregnant, members[].income[].amount) — navigates a data structure at runtime. [] marks an array segment being traversed. Used in Decision.missingFields output (stored as returned by the rules engine), the json-path annotation field (see next decision), and x-enum-source values (build-time data path, e.g., slaTypes[].id). Follows JSONPath conventions (RFC 9535).
Rationale: These are two different addressing systems. Schema keys identify where a field lives in a type definition. JSONPath navigates through assembled data. The distinction is not about [] preference — it is about what the path is for. Mixing formats creates ambiguity and breaks lookups. The x-enum-source extension already uses JSONPath format in the codebase; this decision formalizes the convention. Canonical documentation belongs in docs/architecture/cross-cutting/contract-metadata.md.
Decision.missingFields stores raw composition-shape paths
Decision:Decision.missingFields stores the field paths exactly as returned by the rules engine — paths in the composition shape (e.g., members[].pregnancy.isPregnant) using [] notation for array segments. No translation to Intake resource paths happens in the adapter or Eligibility service.
Rationale: The rules engine returns missing field paths relative to the input it received (the eligibility context composition response). Translating those paths to Intake resource + field pointers would require the Eligibility adapter to know Intake's resource structure — a cross-domain coupling that would need updating whenever the composition config changes. Storing raw paths keeps the adapter simple. Human-friendly display (for caseworker UIs) is handled by the field-level annotation system (see next decision), not by a translation layer.
Field-level annotations for eligibility context schemas include a json-path field
Decision: New Intake resource schemas introduced by this issue get corresponding entries in intake-annotations.yaml following the existing annotation format: SchemaName.fieldName: {programs: [...], policies: [...], dataClassification: [...]}. Each entry also includes a json-path field containing the JSONPath address of that field in the assembled eligibility context response (e.g., json-path: members[].pregnancy.isPregnant). The annotation system supports lookup by either the schema key or the json-path value. The json-path field is formally typed as an optional property in annotations-schema.yaml — not left as an unvalidated extension via additionalProperties: true. Annotations remain in the annotations file (under schema:), not inline in the composition config. Authoring json-path values requires knowing the composition's assembled output shape in advance; this is acceptable because the composition has a designed, fixed output structure. Human-friendly labels are NOT part of the baseline — they are a state overlay extension documented in #131.
Rationale:Decision.missingFields stores raw JSONPath strings as returned by the rules engine. Consumers (caseworker UIs, adapters) need to look up annotations for those paths to display program context, policies, and state-provided labels. Because the annotation system is keyed by schema key (MemberPregnancy.isPregnant) and missing fields arrive as JSONPath (members[].pregnancy.isPregnant), a direct lookup would fail without a bridge. The json-path field on each annotation entry provides that bridge: consumers build a reverse index from JSONPath values to schema keys and look up annotations using whichever format they have. The json-path field uses the JSONPath format defined in the previous decision. Supporting both lookup formats keeps the annotation system usable from any context without requiring callers to translate between formats. Keeping annotations in the annotations file (not the composition config) preserves the annotations file as the single source of truth for field metadata across all consumers — the composition config defines structure, the annotations file describes fields.
Required fixes (in scope)
Plain composition must be DRY with the sectionView panel path
The plain composition path (assemblePlainComposition + fetchNodeItems in composition-assembler.js) is missing three capabilities that the sectionView panel path already has. These are not design differences — they are gaps. Both paths must behave identically for include node results except where the response shape genuinely differs (sections/panels vs. flat record).
Capability
sectionView panel
plain composition
Query param filtering (filterItems)
Yes
Missing
Sorting (sortItems)
Yes
Missing
Pagination (paginateItems)
Yes
Missing — findAll called with limit: null, returns all records
Fix: Thread queryParams and paginationDefaults through assemblePlainComposition from the route handler (req.query). For each include node result set, call filterItems → sortItems → paginateItems in that order, mirroring lines 466–469 of the sectionView panel path. findAll inside fetchNodeItems should respect limit from queryParams, not always use limit: null.
Relevant files:
packages/mock-server/src/composition-assembler.js — fetchNodeItems and assemblePlainComposition
packages/mock-server/src/route-generator.js — assemblePlainComposition call site (~line 614)
The resolved-spec pipeline expands FK references before generating OpenAPI endpoints and schemas. Transition body schemas containing $ref to components/schemas/* fail AJV compilation because the surrounding namespace is not registered. The eligibility context surfaces the same mismatch for any transition endpoint referencing eligibility-context-related schemas. Must be fixed before the composition endpoint is usable end-to-end.
expeditedFlagged is absent from the Determination schema. Any field the rules engine writes back to Determination must be present in the schema. Must be fixed before the composition endpoint is usable end-to-end.
#360 — collectionToSchemaPrefix non-plural word bug
The route generator's collection-to-schema-prefix conversion fails for non-plural resource names. The new resources introduced by this issue will trigger this bug. Must be fixed before the new resources can be generated correctly.
Expected outputs
Intake contracts and architecture:
New Intake resources (each with OpenAPI schema and example data): member-pregnancy, member-veteran-status, member-student-status, member-disability-details, member-living-situation, member-work-requirements, member-immigration-details, member-findings, household-caregiver-relationships, member-tax-info, member-health-coverage, member-tribal-info
New resource schemas defined in schemas/common/intake.yaml (not inline in intake-openapi.yaml); intake-openapi.yaml references them via $ref
Eligibility context plain composition config in intake-compositions.yaml
Field-level annotation entries for all new schemas in intake-annotations.yaml (programs, policies, dataClassification, and json-path per field)
annotations-schema.yaml updated to include json-path as a formally-typed optional property on Annotation objects
Updated docs/architecture/domains/intake.md — all new and extended resource entities with regulatory context, eligibility context composition, updated decisions
Eligibility contracts:
Decision.memberContext field added to eligibility-openapi.yaml — nullable opaque object (type: object, additionalProperties: true) storing the per-member eligibility context at evaluation time
Decision.missingFields field added to eligibility-openapi.yaml — array of composition-shape field paths (using [] notation for array segments) returned by the rules engine when it cannot produce a complete evaluation; field structure and Decision status behavior for the incomplete response path are design questions to be settled during Phase 4
Determination.householdContext field added to eligibility-openapi.yaml — nullable opaque object storing the household-level eligibility context at evaluation time
Eligibility state machine (eligibility-state-machine.yaml) — review whether the evaluate transition needs a step or context binding to call GET /intake/applications/{id}/eligibility-context; design and implement the incomplete rules engine response path (when the rules engine returns missing fields rather than a determination outcome); document changes or explicit "no changes needed" with rationale
Cross-cutting:
Updated docs/architecture/cross-cutting/resource-composition.md — any needed additions for the eligibility context pattern
Updated docs/architecture/cross-cutting/contract-metadata.md — document the two distinct field path formats (schema key vs. JSONPath), when each applies, the json-path annotation field, and the x-enum-source JSONPath usage
Constraints
Intake is the system of record for application data. The composition endpoint lives in Intake; Eligibility stores the response on its own records for audit and post-evaluation access.
States must be able to extend the eligibility context with program-specific inputs via overlay.
States with IRS Pub. 1075 FTI obligations may overlay the eligibility context to limit which income fields are persisted on Decision.memberContext — document this as a known customization point.
Architecture doc covers all new and extended resource entities with regulatory context, the eligibility context composition config, and the adapter integration pattern.
Decision.memberContext, Decision.missingFields, and Determination.householdContext schema shapes are defined in the architecture doc and reflected in eligibility-openapi.yaml.
Eligibility state machine review is documented — either changes are included or "no changes needed" is explicitly stated with rationale. The incomplete rules engine response path (missing fields) is designed and implemented.
Field-level annotations are defined for all new schemas in intake-annotations.yaml.
Two distinct field path formats (schema key and JSONPath) are documented in contract-metadata.md.
The cross-cutting doc reflects any needed additions for the eligibility context pattern.
Plain composition include node results support filtering, sorting, and pagination — parity with the sectionView panel path, covered by unit tests.
The eligibility context composition endpoint is covered by an integration test.
Implementation phases
Phase 0 — Required fixes (unblocking)
Fix the four prerequisite bugs before any new contract work can be validated end-to-end:
Plain composition DRY gap — thread queryParams + paginationDefaults through assemblePlainComposition; add filterItems → sortItems → paginateItems on include node results; fix findAlllimit: null.
Add the corresponding resource entry in intake-openapi.yaml referencing the schema via $ref: ./schemas/common/intake.yaml#/$defs/SchemaName.
Add annotation entries for the new schema's fields in intake-annotations.yaml (programs, policies, dataClassification, and json-path for the JSONPath address in the eligibility context response).
Add example data in intake-openapi-examples.yaml covering at least one record per new resource keyed to the seeded application.
Verify npm run validate passes after each resource addition.
Phase 2 — Extend existing resources
Add the new fields to application-members, household-info, and applications schemas:
Mark all new fields nullable: true (optional at intake time).
Add annotation entries for the new fields.
Add example values to existing example records.
Verify npm run validate passes.
Phase 3 — Eligibility context composition
Author the eligibility-context plain composition config in intake-compositions.yaml. The config assembles: all new member-scoped resources (joined by memberId), extended application-members fields, member-incomes, member-expenses, member-assets, member-employment-records, household-info (extended fields), household-caregiver-relationships, and application-level fields.
Verify the mock server generates GET /intake/applications/{id}/eligibility-context and returns a well-formed response.
Verify ?memberId=abc filters the members[] array to a single member (requires Phase 0 plain composition fix).
Phase 4 — Eligibility contracts
Add Decision.memberContext (nullable opaque object), Decision.missingFields (array of composition-shape field paths), and Determination.householdContext (nullable opaque object) to eligibility-openapi.yaml.
Design the missingFields field structure: confirm string[] of []-notation field paths is sufficient, or determine whether a richer object (with field path, program, and message) better serves caseworker workflows. Settle the Decision status behavior when the rules engine returns missing fields rather than a determination outcome (new status vs. remain in pending).
Review eligibility-state-machine.yamlevaluate transition: determine whether a callAdapter step or context binding is needed to fetch GET /intake/applications/{id}/eligibility-context and bind the result before the rules engine call. Design and implement the incomplete response path. Document outcome (change or explicit "no changes needed" with rationale).
Phase 5 — Documentation
Update docs/architecture/domains/intake.md: add entity descriptions with regulatory context for each new and extended resource; document the eligibility context composition config; record any updated decisions.
Update docs/architecture/cross-cutting/resource-composition.md: add the eligibility context as an example of the plain composition pattern; document IRS Pub. 1075 FTI overlay as a known customization point for income fields on Decision.memberContext.
Update docs/architecture/cross-cutting/contract-metadata.md: document the two distinct field path formats (schema key vs. JSONPath), when each applies, the json-path annotation bridge field, and the x-enum-source JSONPath usage.
Validation steps
Start the mock server (npm run mock:start) after completing Phase 3+. All steps use the seeded application id from intake-openapi-examples.yaml.
Eligibility context — full household:GET /intake/applications/{id}/eligibility-context returns 200 with members[] containing one entry per seeded member, each with pregnancy, veteranStatus, studentStatus, disabilityDetails, livingSituation, workRequirements, immigrationDetails, findings, taxInfo, healthCoverage, tribalInfo, income[], expenses[], assets[], employment[] sub-objects. household contains caregiverRelationships[], extended household-info fields, and application-level fields.
Eligibility context — single member filter:GET /intake/applications/{id}/eligibility-context?memberId={memberId} returns 200 with members[] containing exactly one entry.
Eligibility context — no member match:GET /intake/applications/{id}/eligibility-context?memberId=nonexistent returns 200 with members[] empty.
New resource CRUD:POST /intake/applications/{id}/members/{memberId}/member-pregnancy creates a record and GET returns it. Repeat for at least two other new resources.
Schema validation:npm run validate passes with all new schemas, extended fields, and annotation entries in place.
Integration test: The eligibility-context composition endpoint integration test passes (npm run test:integration).
Unit tests: Plain composition filtering/sorting/pagination unit tests pass (npm test).
Eligibility state machine review: Either the evaluate transition has been updated to fetch and bind the eligibility context, or the architecture doc explicitly states "no changes needed" with rationale. The incomplete rules engine response path is implemented and tested.
Summary
The eligibility domain's rules engine adapter needs a structured view of household member data to evaluate SNAP and Medicaid eligibility. No such endpoint exists today. Issue #354 proposed a bespoke hand-crafted endpoint (EligibilitySnapshot with PUT upsert semantics); that PR was closed without merging. This issue replaces that approach: the eligibility context should be expressed as a plain declarative composition (using the infrastructure from #357) that assembles member-level data from multiple intake sub-resources into a single GET endpoint. This issue produces updates to the intake architecture doc and the composition cross-cutting doc.
Reference branch for baseline design thinking:
origin/feat/eligibility-snapshot(the unmerged #354 branch). The docs on that branch (intake.md, eligibility.md, inter-domain-communication.md) represent the latest thinking even though the implementation was not merged. The decisions below record where we have departed from that baseline and why.Settled decisions
GET-based live assembly, not PUT-based materialization
Decision: The composition endpoint is a standard GET. The Eligibility state machine calls
GET /intake/applications/{id}/eligibility-contextbefore each evaluate call; the composition runtime assembles current data from Intake's sub-resources and returns it. No stored EligibilitySnapshot resource in Intake; no PUT upsert.Rationale: The #354 design used PUT upsert so that PII would stay in Intake's storage boundary (the snapshot is assembled and returned but not persisted in Eligibility). That constraint does not hold: the "determination context" that the rules engine uses must be stored with the Determination for audit and appeal purposes — you cannot satisfy federal audit requirements without a durable record of the inputs used to reach an outcome. The PUT approach also didn't eliminate the runtime dependency on Intake (the state machine still calls PUT synchronously before evaluate); it just shifted when the call happens. GET-based live assembly is architecturally equivalent at evaluate time and fits the composition infrastructure directly without extension.
PII flows to Eligibility's Decision records
Decision: The GET response is stored as Decision.memberContext and Determination.householdContext on Eligibility's records. This is the "determination context" pattern: the domain that owns the outcome owns the inputs that produced it.
Rationale: IBM Cúram's determination context, Salesforce Government Cloud's evaluation objects, and CalSAWS all store input snapshots with the determination record — not in the source domain. The #354 approach (audit trail via event log replay of EligibilitySnapshot.updated events) is operationally fragile: it requires the event log to be queryable, replayable, and long-lived, which varies significantly across state deployments. Stored fields on Determination/Decision are durable, queryable, and portable. States with strict FTI controls (IRS Pub. 1075) may need to overlay the eligibility context to limit which income fields are persisted on Decision.memberContext — that is an overlay concern, not a baseline design constraint.
Assembly logic stays in Intake
Decision: The composition config lives in Intake (the owning domain). Eligibility calls one endpoint and receives a pre-built payload without knowing which sub-resources contribute or how they are joined.
Rationale: Intake holds the authoritative application data and knows its own internal resource structure. If assembly logic lived in Eligibility's state machine or in the adapter, every Intake model change would require an Eligibility change. The composition config encodes the assembly contract in a single place, overlay-extensible by states.
Availability dependency is accepted
Decision: The GET call to Intake at evaluate time is an accepted synchronous dependency on a co-deployed service. No fallback to cached data is built into the baseline.
Rationale: The PUT upsert approach did not eliminate this dependency — the state machine still called PUT synchronously before evaluate. The only way to eliminate the dependency is event-driven push (Intake publishes updates when data changes; Eligibility maintains a continuously-synchronized projection) — that is significantly more complex and no major platform does it for eligibility evaluation. In practice, Intake and Eligibility are co-deployed; downtime is correlated. Post-evaluation access (audit, appeal, supervisor review) uses the stored Decision.memberContext and does not require Intake to be available.
Application-scoped endpoint with query param filtering
Decision: One composition endpoint per application (
GET /intake/applications/{id}/eligibility-context). Callers that need a single member's data pass?memberId=abc.Rationale: SNAP evaluates the household as a unit — it needs all members in one call. Medicaid ex parte evaluates per person — callers can filter to a single member via query param. A separate per-member endpoint would require the rules engine adapter to know which endpoint to call per program type; that routing logic belongs in the composition config, not spread across callers. The plain composition filtering gap (see Required fixes) enables
?memberId=abcto work once fixed.Resource structure mirrors the eligibility input data model
Each logical grouping of eligibility input data maps to exactly one Intake resource. The structure follows the rules engine's input model directly: root-level properties extend existing resources; named sub-objects become new singleton resources; multi-valued arrays are existing or new collection resources. Resource names match the corresponding input model category names.
New member-scoped resources (one record per member, bound by
applicationId+memberId):member-pregnancymembers[].pregnancymember-veteran-statusmembers[].veteranStatusmember-student-statusmembers[].studentStatusmember-disability-detailsmembers[].disabilityDetailsmember-living-situationmembers[].livingSituationmember-work-requirementsmembers[].workRequirementsmember-immigration-detailsmembers[].immigrationDetailsmember-findingsmembers[].findingsmember-tax-infomembers[].taxInfomember-health-coveragemembers[].healthCoveragemember-tribal-infomembers[].tribalInfoExisting member-scoped resources extended with root-level fields:
application-membersExisting member-scoped collection resources (no changes needed):
member-incomesmembers[].income[]member-expensesmembers[].expenses[]member-assetsmembers[].assets[]member-employment-recordsmembers[].employment[]New household-scoped resource (multi-valued — one record per caregiver/dependent pair):
household-caregiver-relationshipshousehold.caregiverRelationships[]Existing household-scoped resource extended with root-level fields:
household-infoExisting application-scoped resource extended with root-level fields:
applicationsSponsorship belongs with immigration details, not as a standalone resource
Decision: Sponsor data is a sub-record on
member-immigration-details, not a separate resource.Rationale: Sponsorship is only relevant for non-citizen members and is inherently tied to immigration status. The rules engine treats sponsor data as a nested object within immigration details. A standalone sponsorship resource would require a join back to the member's immigration record to be meaningful — unnecessary indirection.
No backing state resource needed
Decision: The eligibility context does not need a backing state resource at the baseline.
Rationale: Post-evaluation access uses the stored Decision.memberContext. Whether data has changed since the last rules engine run is tracked via the Determination's evaluation timestamps on the Eligibility side, not via a state resource in Intake. States that need change-detection can overlay a state resource.
Eligibility context fields are opaque objects, not schema mirrors
Decision:
Decision.memberContextandDetermination.householdContextare typed astype: objectwithadditionalProperties: true— opaque blobs. They are not given schemas that mirror the Intake composition response shape.Rationale: These fields are audit snapshots — they record whatever the rules engine received at evaluation time, not a live view of Intake data. The exact shape is defined by the rules engine's input model, which is external to the blueprint and varies by state. Duplicating the Intake composition shape in Eligibility's OpenAPI spec would create a maintenance coupling: every change to the composition would require an Eligibility schema update. Salesforce Government Cloud's evaluation objects and IBM Cúram's determination context both store the input payload as a flexible structure, not a typed mirror of the source domain's schema.
Update events suffice for progressive evaluation audit
Decision: Storing
memberContextas a single overwritable field on Decision is sufficient for progressive determination. No versioned history or array of snapshots is needed on the record itself.Rationale: The platform emits
ResourceUpdatedEventautomatically on every PATCH, carrying before/after values for all changed fields. Each evaluate pass that writes a new value tomemberContextproduces adecision.updatedevent with{field: "memberContext", before: {...}, after: {...}}. The event log preserves the complete evaluation history. The current field value reflects the latest evaluation state; prior states are recoverable from the log. This is equivalent in durability to Cúram's determination context pattern and does not require any additional audit infrastructure.New resource schemas defined in schemas/common/, not inline in OpenAPI
Decision: New Intake resource schemas introduced by this issue (
MemberPregnancy,MemberVeteranStatus, etc.) are defined as JSON Schema objects inschemas/common/intake.yaml.intake-openapi.yamland state machine files reference them via$ref: ./schemas/common/intake.yaml#/$defs/SchemaName. They are not defined inline in the OpenAPI spec.Rationale: Resource schemas must be usable by both the OpenAPI spec and state machine transition request/response validation. Inline OpenAPI schemas are not accessible to state machines. The
schemas/common/pattern is already established for shared enums and types (enums.yaml,member.yaml,household.yaml, etc.), and the meta-schema already uses external$reftoschemas/common/enums.yaml. Extending this pattern to resource schemas avoids duplication between the API contract and the state machine validation layer.Two distinct field path formats: schema key and JSONPath
Decision: The blueprint uses two distinct field path formats for different purposes. They must not be mixed.
Schema key (
SchemaName.fieldName, e.g.,MemberPregnancy.isPregnant) — identifies a field by its location in a schema. Used for annotation keys, sort/search nested field params, and any context where a field is named by schema location rather than navigated to through data. No array traversal notation.JSONPath (e.g.,
members[].pregnancy.isPregnant,members[].income[].amount) — navigates a data structure at runtime.[]marks an array segment being traversed. Used inDecision.missingFieldsoutput (stored as returned by the rules engine), thejson-pathannotation field (see next decision), andx-enum-sourcevalues (build-time data path, e.g.,slaTypes[].id). Follows JSONPath conventions (RFC 9535).Rationale: These are two different addressing systems. Schema keys identify where a field lives in a type definition. JSONPath navigates through assembled data. The distinction is not about
[]preference — it is about what the path is for. Mixing formats creates ambiguity and breaks lookups. Thex-enum-sourceextension already uses JSONPath format in the codebase; this decision formalizes the convention. Canonical documentation belongs indocs/architecture/cross-cutting/contract-metadata.md.Decision.missingFields stores raw composition-shape paths
Decision:
Decision.missingFieldsstores the field paths exactly as returned by the rules engine — paths in the composition shape (e.g.,members[].pregnancy.isPregnant) using[]notation for array segments. No translation to Intake resource paths happens in the adapter or Eligibility service.Rationale: The rules engine returns missing field paths relative to the input it received (the eligibility context composition response). Translating those paths to Intake resource + field pointers would require the Eligibility adapter to know Intake's resource structure — a cross-domain coupling that would need updating whenever the composition config changes. Storing raw paths keeps the adapter simple. Human-friendly display (for caseworker UIs) is handled by the field-level annotation system (see next decision), not by a translation layer.
Field-level annotations for eligibility context schemas include a json-path field
Decision: New Intake resource schemas introduced by this issue get corresponding entries in
intake-annotations.yamlfollowing the existing annotation format:SchemaName.fieldName: {programs: [...], policies: [...], dataClassification: [...]}. Each entry also includes ajson-pathfield containing the JSONPath address of that field in the assembled eligibility context response (e.g.,json-path: members[].pregnancy.isPregnant). The annotation system supports lookup by either the schema key or thejson-pathvalue. Thejson-pathfield is formally typed as an optional property inannotations-schema.yaml— not left as an unvalidated extension viaadditionalProperties: true. Annotations remain in the annotations file (underschema:), not inline in the composition config. Authoringjson-pathvalues requires knowing the composition's assembled output shape in advance; this is acceptable because the composition has a designed, fixed output structure. Human-friendly labels are NOT part of the baseline — they are a state overlay extension documented in #131.Rationale:
Decision.missingFieldsstores raw JSONPath strings as returned by the rules engine. Consumers (caseworker UIs, adapters) need to look up annotations for those paths to display program context, policies, and state-provided labels. Because the annotation system is keyed by schema key (MemberPregnancy.isPregnant) and missing fields arrive as JSONPath (members[].pregnancy.isPregnant), a direct lookup would fail without a bridge. Thejson-pathfield on each annotation entry provides that bridge: consumers build a reverse index from JSONPath values to schema keys and look up annotations using whichever format they have. Thejson-pathfield uses the JSONPath format defined in the previous decision. Supporting both lookup formats keeps the annotation system usable from any context without requiring callers to translate between formats. Keeping annotations in the annotations file (not the composition config) preserves the annotations file as the single source of truth for field metadata across all consumers — the composition config defines structure, the annotations file describes fields.Required fixes (in scope)
Plain composition must be DRY with the sectionView panel path
The plain composition path (
assemblePlainComposition+fetchNodeItemsincomposition-assembler.js) is missing three capabilities that the sectionView panel path already has. These are not design differences — they are gaps. Both paths must behave identically for include node results except where the response shape genuinely differs (sections/panels vs. flat record).filterItems)sortItems)paginateItems)findAllcalled withlimit: null, returns all recordsFix: Thread
queryParamsandpaginationDefaultsthroughassemblePlainCompositionfrom the route handler (req.query). For each include node result set, callfilterItems→sortItems→paginateItemsin that order, mirroring lines 466–469 of the sectionView panel path.findAllinsidefetchNodeItemsshould respectlimitfrom queryParams, not always uselimit: null.Relevant files:
packages/mock-server/src/composition-assembler.js—fetchNodeItemsandassemblePlainCompositionpackages/mock-server/src/route-generator.js—assemblePlainCompositioncall site (~line 614)#358 — State-machine FK expansion bug
The resolved-spec pipeline expands FK references before generating OpenAPI endpoints and schemas. Transition body schemas containing
$reftocomponents/schemas/*fail AJV compilation because the surrounding namespace is not registered. The eligibility context surfaces the same mismatch for any transition endpoint referencing eligibility-context-related schemas. Must be fixed before the composition endpoint is usable end-to-end.#359 — Missing
expeditedFlaggedon DeterminationexpeditedFlaggedis absent from the Determination schema. Any field the rules engine writes back to Determination must be present in the schema. Must be fixed before the composition endpoint is usable end-to-end.#360 —
collectionToSchemaPrefixnon-plural word bugThe route generator's collection-to-schema-prefix conversion fails for non-plural resource names. The new resources introduced by this issue will trigger this bug. Must be fixed before the new resources can be generated correctly.
Expected outputs
Intake contracts and architecture:
member-pregnancy,member-veteran-status,member-student-status,member-disability-details,member-living-situation,member-work-requirements,member-immigration-details,member-findings,household-caregiver-relationships,member-tax-info,member-health-coverage,member-tribal-infoapplication-members,household-info,applicationsschemas/common/intake.yaml(not inline inintake-openapi.yaml);intake-openapi.yamlreferences them via$refintake-compositions.yamlintake-annotations.yaml(programs, policies, dataClassification, andjson-pathper field)annotations-schema.yamlupdated to includejson-pathas a formally-typed optional property onAnnotationobjectsdocs/architecture/domains/intake.md— all new and extended resource entities with regulatory context, eligibility context composition, updated decisionsEligibility contracts:
Decision.memberContextfield added toeligibility-openapi.yaml— nullable opaque object (type: object, additionalProperties: true) storing the per-member eligibility context at evaluation timeDecision.missingFieldsfield added toeligibility-openapi.yaml— array of composition-shape field paths (using[]notation for array segments) returned by the rules engine when it cannot produce a complete evaluation; field structure and Decision status behavior for the incomplete response path are design questions to be settled during Phase 4Determination.householdContextfield added toeligibility-openapi.yaml— nullable opaque object storing the household-level eligibility context at evaluation timeeligibility-state-machine.yaml) — review whether theevaluatetransition needs a step or context binding to callGET /intake/applications/{id}/eligibility-context; design and implement the incomplete rules engine response path (when the rules engine returns missing fields rather than a determination outcome); document changes or explicit "no changes needed" with rationaleCross-cutting:
docs/architecture/cross-cutting/resource-composition.md— any needed additions for the eligibility context patterndocs/architecture/cross-cutting/contract-metadata.md— document the two distinct field path formats (schema key vs. JSONPath), when each applies, thejson-pathannotation field, and thex-enum-sourceJSONPath usageConstraints
feat/declarative-resource-composition(Declarative resource composition #357), notmain.Definition of done
Decision.memberContext,Decision.missingFields, andDetermination.householdContextschema shapes are defined in the architecture doc and reflected ineligibility-openapi.yaml.intake-annotations.yaml.contract-metadata.md.Implementation phases
Phase 0 — Required fixes (unblocking)
Fix the four prerequisite bugs before any new contract work can be validated end-to-end:
queryParams+paginationDefaultsthroughassemblePlainComposition; addfilterItems→sortItems→paginateItemson include node results; fixfindAlllimit: null.components/schemas/*namespace with AJV before compiling individual transition body schemas.expeditedFlagged— add field to Determination schema.collectionToSchemaPrefixnon-plural bug — fix route generator so non-plural resource names are handled correctly.Phase 1 — New Intake resources
For each new resource listed in the settled decisions:
schemas/common/intake.yamlwith all required fields, types, andnullable: trueon optional fields per Optional fields in response schemas should be nullable #369.intake-openapi.yamlreferencing the schema via$ref: ./schemas/common/intake.yaml#/$defs/SchemaName.intake-annotations.yaml(programs, policies, dataClassification, andjson-pathfor the JSONPath address in the eligibility context response).intake-openapi-examples.yamlcovering at least one record per new resource keyed to the seeded application.npm run validatepasses after each resource addition.Phase 2 — Extend existing resources
Add the new fields to
application-members,household-info, andapplicationsschemas:nullable: true(optional at intake time).npm run validatepasses.Phase 3 — Eligibility context composition
eligibility-contextplain composition config inintake-compositions.yaml. The config assembles: all new member-scoped resources (joined bymemberId), extendedapplication-membersfields,member-incomes,member-expenses,member-assets,member-employment-records,household-info(extended fields),household-caregiver-relationships, and application-level fields.GET /intake/applications/{id}/eligibility-contextand returns a well-formed response.?memberId=abcfilters themembers[]array to a single member (requires Phase 0 plain composition fix).Phase 4 — Eligibility contracts
Decision.memberContext(nullable opaque object),Decision.missingFields(array of composition-shape field paths), andDetermination.householdContext(nullable opaque object) toeligibility-openapi.yaml.missingFieldsfield structure: confirm string[] of[]-notation field paths is sufficient, or determine whether a richer object (with field path, program, and message) better serves caseworker workflows. Settle the Decision status behavior when the rules engine returns missing fields rather than a determination outcome (new status vs. remain in pending).eligibility-state-machine.yamlevaluatetransition: determine whether acallAdapterstep or context binding is needed to fetchGET /intake/applications/{id}/eligibility-contextand bind the result before the rules engine call. Design and implement the incomplete response path. Document outcome (change or explicit "no changes needed" with rationale).Phase 5 — Documentation
docs/architecture/domains/intake.md: add entity descriptions with regulatory context for each new and extended resource; document the eligibility context composition config; record any updated decisions.docs/architecture/cross-cutting/resource-composition.md: add the eligibility context as an example of the plain composition pattern; document IRS Pub. 1075 FTI overlay as a known customization point for income fields on Decision.memberContext.docs/architecture/cross-cutting/contract-metadata.md: document the two distinct field path formats (schema key vs. JSONPath), when each applies, thejson-pathannotation bridge field, and thex-enum-sourceJSONPath usage.Validation steps
Start the mock server (
npm run mock:start) after completing Phase 3+. All steps use the seeded application id fromintake-openapi-examples.yaml.Eligibility context — full household:
GET /intake/applications/{id}/eligibility-contextreturns 200 withmembers[]containing one entry per seeded member, each withpregnancy,veteranStatus,studentStatus,disabilityDetails,livingSituation,workRequirements,immigrationDetails,findings,taxInfo,healthCoverage,tribalInfo,income[],expenses[],assets[],employment[]sub-objects.householdcontainscaregiverRelationships[], extendedhousehold-infofields, and application-level fields.Eligibility context — single member filter:
GET /intake/applications/{id}/eligibility-context?memberId={memberId}returns 200 withmembers[]containing exactly one entry.Eligibility context — no member match:
GET /intake/applications/{id}/eligibility-context?memberId=nonexistentreturns 200 withmembers[]empty.New resource CRUD:
POST /intake/applications/{id}/members/{memberId}/member-pregnancycreates a record andGETreturns it. Repeat for at least two other new resources.Schema validation:
npm run validatepasses with all new schemas, extended fields, and annotation entries in place.Integration test: The
eligibility-contextcomposition endpoint integration test passes (npm run test:integration).Unit tests: Plain composition filtering/sorting/pagination unit tests pass (
npm test).Eligibility state machine review: Either the
evaluatetransition has been updated to fetch and bind the eligibility context, or the architecture doc explicitly states "no changes needed" with rationale. The incomplete rules engine response path is implemented and tested.Dependencies / Related
expeditedFlaggedon Determination (must be fixed in Phase 0)collectionToSchemaPrefixnon-plural word bug (must be fixed in Phase 0)