Skip to content

Open Source Update Strategy | Phase 1 | Set up Tier 1 and Tier 2 customization infrastructure #540

@giverm

Description

@giverm

Open Source Update Strategy | Phase 1 | Set up Tier 1 and Tier 2 customization infrastructure

Body revised 2026-05-15 to expand scope to both Tier 1 (YAML domain config) and Tier 2 (locale overrides). Original ticket "Extract state-configurable policy to YAML" was Tier 1 only; during implementation an empirical verification of the locale-override design exposed a load-order assumption that required new plumbing in config/application.rb. Both tiers now ship together so the first tagged release (#531) carries the full basic-customization story.

User stories

Tier 1 — YAML domain config. As a state implementer whose program has a different exemption list than OSCER's reference deployment (e.g., recognizes medical_condition and caregiver_child but not education_and_training), I want to enable or disable types via YAML so I don't fork Ruby initializers and carry that fork through every update.

Tier 2 — locale overrides. As a state implementer renaming "Community Engagement" to my state's terminology, I want to override OSCER's locale strings via files in a deployment-owned directory, so I don't edit template-owned config/locales/<locale>.yml and hit merge conflicts on every nava-platform app update.

Context

OSCER's working-assumptions doc (docs/hr1-working-assumptions.md) commits to keeping state-configurable policy open to each deployment. Today, exemption types are hardcoded in config/initializers/exemption_types.rb (Tier 2 of the customization ladder — edit Ruby). And OSCER ships 36 locale files across config/locales/**, all template-owned — there's no zero-conflict way for an implementer to override strings without editing template files and inheriting merge conflicts on every update.

This ticket closes both gaps. Tier 1 moves exemption-type composition from "edit Ruby" to "edit YAML" via a base+override pattern. Tier 2 adds a deployment-owned config/locales/overrides/ directory with explicit load-path ordering so override keys win on duplicate-key conflicts. Both tiers use the same conceptual shape (template-owned defaults + deployment-owned overrides + Copier-managed preservation of deployment edits across updates) and establish reusable infrastructure for future state-configurable categories.

Strategy detail: #213 Section 4 (customization ladder Tier 1–4).

Tier 1 — YAML domain config

Two YAML files plus a loader module replace config/initializers/exemption_types.rb:

  • config/ce_config_base.yml — template-owned defaults, hash-of-hashes shape so deep_merge composes against an optional deployment override.
  • config/ce_config.yml — deployment-owned, NOT committed. Preserved across Copier updates via #528 _skip_if_exists scope.
  • app/services/ce_configuration.rbCEConfiguration module: deep-merges, validates per-entry shape, transforms hash-of-hashes into the array-of-hashes shape Rails.application.config.exemption_types already exposes. Strict YAML.safe_load, nested ConfigurationError, top-level Hash guard, per-entry validation pre-merge.
  • config/initializers/ce_configuration.rb — 3-line wrapper.

Exemption and downstream consumers (screener navigator, application form enum, request specs) are unchanged.

Tier 2 — Locale-override infrastructure

  • config/application.rb — explicit two-step I18n.load_path ordering. Base files load first; files under config/locales/overrides/ load last so override keys win on duplicate-key conflicts.
  • config/locales/overrides/README.md — deployment-owned directory with a discoverability nudge.

A naive recursive config/locales/**/*.{rb,yml} glob places overrides/ alphabetically before services/ and views/, which contain 29 of OSCER's 36 locale files. Overrides would silently lose to any key defined in those directories. Explicit load-order plumbing avoids the failure mode and is resilient to future locale subdirectory additions.

Sequencing within Phase 1

Promoted from Phase 2 to Phase 1 per team discussion 2026-05-12: basic customization infrastructure is load-bearing for first-implementer adoption rather than a post-adoption ergonomic improvement. Lands before #531 so v2026.N.0 ships with both YAML and locale-override customization concretely working. Independent of #528 (template repo creation) — can be worked in parallel, but #528 must add config/ce_config.yml to its _skip_if_exists list before the Tier 1 customization story works end-to-end on a fresh implementer install.

Scope

In this ticket:

  • Tier 1: config/ce_config_base.yml, app/services/ce_configuration.rb, config/initializers/ce_configuration.rb (replaces config/initializers/exemption_types.rb).
  • Tier 2: config/application.rb load-order plumbing, config/locales/overrides/README.md.
  • Test coverage: spec/services/ce_configuration_spec.rb (Tier 1 loader behavior), spec/initializers/i18n_load_path_spec.rb (Tier 2 load-order plumbing).
  • Preserve existing TODO ("Add federal disaster declaration and medical care travel") as a YAML comment in ce_config_base.yml.

Out of this ticket:

  • documentation_requirements extraction — second state-configurable category; the CEConfiguration infrastructure accommodates it but actually extracting it is a separate ticket.
  • VA disability threshold (combined_rating == 100 in Rules::ExemptionRuleset) — federally-opinionated by design.
  • The exemption rules engine itself — federally-opinionated.
  • CUSTOMIZATION.md describing both tiers for implementers — covered by #539.
  • Copier _skip_if_exists configuration for config/ce_config.yml — landed in #528.

Acceptance criteria

  • config/ce_config_base.yml exists with all 7 current exemption types in hash-of-hashes shape.
  • config/initializers/ce_configuration.rb loads the base file and deep-merges an optional config/ce_config.yml.
  • Rails.application.config.exemption_types returns the same [{id: Symbol, enabled: Boolean, ...}, ...] shape as before (existing spec/models/exemption_spec.rb passes unchanged).
  • A deployer who creates config/ce_config.yml can disable a default type or add a new one without touching Ruby.
  • A clear boot-time CEConfiguration::ConfigurationError surfaces when YAML is missing required keys, malformed, or has non-Hash entries.
  • config/locales/overrides/ exists as a deployment-owned path with a README.md discoverability nudge.
  • config/application.rb loads override locale files AFTER all base locale files in I18n.load_path, regardless of alphabetical position.
  • Test coverage demonstrates both tiers including the override-wins property for locale keys defined in services/ or views/.

Reference implementation

Shipped in PR #564.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions