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.rb — CEConfiguration 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
Reference implementation
Shipped in PR #564.
Open Source Update Strategy | Phase 1 | Set up Tier 1 and Tier 2 customization infrastructure
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_conditionandcaregiver_childbut noteducation_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>.ymland hit merge conflicts on everynava-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 inconfig/initializers/exemption_types.rb(Tier 2 of the customization ladder — edit Ruby). And OSCER ships 36 locale files acrossconfig/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 sodeep_mergecomposes against an optional deployment override.config/ce_config.yml— deployment-owned, NOT committed. Preserved across Copier updates via #528_skip_if_existsscope.app/services/ce_configuration.rb—CEConfigurationmodule: deep-merges, validates per-entry shape, transforms hash-of-hashes into the array-of-hashes shapeRails.application.config.exemption_typesalready exposes. StrictYAML.safe_load, nestedConfigurationError, top-levelHashguard, per-entry validation pre-merge.config/initializers/ce_configuration.rb— 3-line wrapper.Exemptionand downstream consumers (screener navigator, application form enum, request specs) are unchanged.Tier 2 — Locale-override infrastructure
config/application.rb— explicit two-stepI18n.load_pathordering. Base files load first; files underconfig/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 placesoverrides/alphabetically beforeservices/andviews/, 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.0ships with both YAML and locale-override customization concretely working. Independent of #528 (template repo creation) — can be worked in parallel, but #528 must addconfig/ce_config.ymlto its_skip_if_existslist before the Tier 1 customization story works end-to-end on a fresh implementer install.Scope
In this ticket:
config/ce_config_base.yml,app/services/ce_configuration.rb,config/initializers/ce_configuration.rb(replacesconfig/initializers/exemption_types.rb).config/application.rbload-order plumbing,config/locales/overrides/README.md.spec/services/ce_configuration_spec.rb(Tier 1 loader behavior),spec/initializers/i18n_load_path_spec.rb(Tier 2 load-order plumbing).ce_config_base.yml.Out of this ticket:
documentation_requirementsextraction — second state-configurable category; theCEConfigurationinfrastructure accommodates it but actually extracting it is a separate ticket.combined_rating == 100inRules::ExemptionRuleset) — federally-opinionated by design._skip_if_existsconfiguration forconfig/ce_config.yml— landed in #528.Acceptance criteria
config/ce_config_base.ymlexists with all 7 current exemption types in hash-of-hashes shape.config/initializers/ce_configuration.rbloads the base file and deep-merges an optionalconfig/ce_config.yml.Rails.application.config.exemption_typesreturns the same[{id: Symbol, enabled: Boolean, ...}, ...]shape as before (existingspec/models/exemption_spec.rbpasses unchanged).config/ce_config.ymlcan disable a default type or add a new one without touching Ruby.CEConfiguration::ConfigurationErrorsurfaces when YAML is missing required keys, malformed, or has non-Hash entries.config/locales/overrides/exists as a deployment-owned path with aREADME.mddiscoverability nudge.config/application.rbloads override locale files AFTER all base locale files inI18n.load_path, regardless of alphabetical position.services/orviews/.Reference implementation
Shipped in PR #564.