Decision: Tiered settings with eager initialisation (no NULL-inheritance)
Decided on 2026-05-18.
The decision in one sentence
Gig-specific configuration (night band hours, auto-selection, settlement deadline, and future per-tenant settings) uses two tables with explicit values at every level: gig_company_settings (one row per company, seeded from system defaults at onboarding) and gig_outlet_settings (one row per outlet, seeded by copying the parent company's values at outlet creation). Every column is NOT NULL. After creation, each row is independent — there is no read-time fallback, no NULL-as-inheritance, and no retroactive propagation from company to existing outlets.
The question we asked
The PayRate spec needs a default time-of-day band for the "night" rate UI pre-fill (HR clicks "Add a night band" and the form fills with starts_at: 18:00, ends_at: 06:00). Today this lives on gig_company_settings as a per-company value. But within one company, different outlets can have genuinely different operational hours:
- The Tampines Mall outlet closes at 23:00; "night" starts at 18:00.
- The Tuas outlet is a 24-hour operation; "night" starts at 22:00.
Where should the override live? Three reasonable answers:
- Move the column to
Org::Outletand require every outlet to set its own value. - Keep it on
gig_company_settingsonly, accept that the per-outlet variation is not supported. - Build a tiered settings pattern: company defines a default, outlets diverge when they need to.
Answer 3 is the standard pattern in multi-tenant SaaS for exactly this situation. The remaining question is how to implement it: with NULL-as-inheritance (lazy cascade), or with eager initialisation and explicit values everywhere.
We pick eager initialisation with explicit values. This note records the decision and the supporting design.
The short answer — and why this matters beyond night bands
The pattern is not just about night-band hours. It is a platform pattern that applies to every per-tenant configuration question Jod will encounter for the rest of the product's life:
- Night band hours (immediate use case)
- Auto-selection on/off (already in
gig_company_settings) - Settlement deadline hour (already in
gig_company_settings) - Future: booking lead time, cancellation windows, approval thresholds, currency rounding, default shift length
Without a pattern, each new setting becomes its own design conversation about "where does this live, can it be overridden, by whom?". With this pattern documented once, the answer for every future setting is mechanical: add a NOT NULL column to both gig_company_settings and gig_outlet_settings, add the system default to the constants file, ensure the outlet-creation Manager copies the new column. Consumers read directly from the level they need.
The night-band use case is just the trigger.
The pattern, plainly
System defaults (constants in code: Gig::Settings::DEFAULTS)
│
│ seeded at onboarding (UC-1 on Gig::CompanySetting)
▼
gig_company_settings (one row per company; all columns NOT NULL; explicit values)
│
│ values copied at outlet creation (UC-1 on Gig::OutletSetting)
▼
gig_outlet_settings (one row per outlet; all columns NOT NULL; explicit values)
│
│ direct read by consumers; no walk-up
▼
Consumers (PayRate UI pre-fill, settlement scheduler, auto-selection scheduling)
The "cascade" is a write-time / create-time concept: parent values seed child rows at creation. At read time, every consumer reads directly from the appropriate level's row. No fallback walk. No NULL to interpret.
What "independent" means after creation
When HR edits gig_company_settings, existing gig_outlet_settings rows are not touched. The edit only affects:
- New outlets created after this point (they will be seeded with the new company value).
- Company-wide PayRate creation forms (the UI pre-fill will use the new value).
Outlets that were created before the edit keep their previously-seeded values. This is deliberate. If McD raises the company default night_shift_start_hour from 18 to 19, the Tuas outlet (which was already overridden to 22 for its 24-hour operation) does not silently change. The Tampines outlet (which is currently at 18) does not silently flip to 19 either — that would be a behaviour change at the outlet level without the outlet manager's awareness. HR who wants to propagate the change must edit each outlet's row deliberately.
Why we chose eager + explicit over NULL-as-inheritance
The alternative (NULL columns at outlet level, walk up to company at read time) was the first design we wrote. We rejected it after thinking through the operational reality. Four reasons:
Reason 1: NULLs make HR lazy
If gig_outlet_settings.night_shift_start_hour is nullable with "NULL = inherit", almost no HR will ever set it. They never have a reason to think about it. Then the day comes when ops asks "why is this outlet running on the company default of 18 when the outlet manager has been complaining about late shifts?", and the answer is "because nobody set it" — which is a process failure, not a decision.
Explicit values force HR to look at the value during onboarding (it is right there on the form, pre-filled with the company value) and decide whether to keep or change. The cost is one extra UI step at onboarding; the benefit is that every value in the system was once seen by a human.
Reason 2: NULLs make data analysis ambiguous
The data team writes:
SELECT o.name, gos.night_shift_start_hour
FROM gig_outlet_settings gos
JOIN org_outlets o ON o.id = gos.org_outlet_id
WHERE o.id IN (...)
With explicit values, the result is concrete and immediately usable. With NULL-as-inheritance, the analyst has to either join gig_company_settings and COALESCE, or know to walk up the chain. Both add cognitive load to every analytics task. Multiply by every cascade-eligible setting and the friction compounds.
The analyst's question "why is the setting NULL but they can create pay rates?" is a real one we have heard in similar SaaS architectures. The eager+explicit pattern removes it entirely.
Reason 3: NULLs let system-default changes propagate silently
With NULL-as-inheritance, changing Gig::Settings::DEFAULTS[:night_shift_start_hour] from 18 to 19 instantly changes the resolved value for every outlet and company that has NULL in that column. No notification, no audit. A developer-side change in a constants file silently alters customer-facing behaviour.
With explicit values, the constant is only read at row-creation time. Existing rows are unaffected. The default change applies only to new companies and new outlets going forward. Behaviour changes are bounded and visible.
Reason 4: Eager rows are cheap
A multi-outlet chain has maybe a few hundred outlets at most. The storage cost of one row per outlet in gig_outlet_settings is negligible. The conceptual saving from "sparse table" (row exists = override) is real but small compared to the data-team friction it creates. Eager rows for everyone is the simpler-to-explain design.
Three concrete walkthroughs
Example 1: McDonald's onboards with default everything
- A new
Org::Companyis created for McDonald's. - The company-creation Manager calls
Gig::CompanySettings::CreateService.execute(org_company:)in the same transaction. - One
gig_company_settingsrow is inserted withnight_shift_start_hour: 18,night_shift_end_hour: 6,auto_selection_enabled: true,settlement_deadline_hour: 9(all fromGig::Settings::DEFAULTS). - McDonald's opens its first outlet (Tampines Mall). The outlet-creation Manager calls
Gig::OutletSettings::CreateService.execute(org_outlet:). - The service reads the McDonald's
gig_company_settingsrow and inserts onegig_outlet_settingsrow with the same four values copied across. - HR creates a Casual Crew PayRate for Tampines. The "Add night band" preset reads from
gig_outlet_settingsfor Tampines → pre-fillsstarts_at: 18:00, ends_at: 06:00. HR saves the PayRate.
Total rows: 1 company settings + N outlet settings + the rate cards. No NULLs anywhere.
Example 2: NTUC FairPrice has a 24-hour outlet
- NTUC FairPrice onboards. Company settings row created with defaults.
- HR edits the company settings:
night_shift_start_hour: 22(most NTUC outlets run late, so company default reflects this). - NTUC opens the Tampines outlet. Outlet settings row created with values copied from current company settings:
night_shift_start_hour: 22, etc. - NTUC opens the Tuas Mega Mart outlet (24-hour operation; night premium starts at 23). Outlet settings row created with values copied from current company settings:
night_shift_start_hour: 22. - HR edits the Tuas outlet settings:
night_shift_start_hour: 23. Only Tuas changes. Tampines stays at 22. - Reads:
- Tuas: night band pre-fill is 23:00–06:00.
- Tampines: night band pre-fill is 22:00–06:00.
- Any company-wide PayRate creation: night band pre-fill is 22:00–06:00 (the current company value).
No fallback walk. Every value visible in the row it lives on.
Example 3: System default changes; existing data unaffected
- The platform team decides the default
settlement_deadline_hourshould be 8 (one hour earlier) instead of 9. They change the constant inGig::Settings::DEFAULTS. - Every existing
gig_company_settingsandgig_outlet_settingsrow keeps its current value (9, whatever was seeded previously). No silent behaviour change. - A new company onboards. Its
gig_company_settingsrow is seeded withsettlement_deadline_hour: 8. - That new company opens its first outlet. The outlet's settings row copies
8from the company. - Old companies and their outlets continue to use
9unless HR edits them.
The platform default change is bounded to new accounts going forward. Existing customer behaviour is stable.
What we considered and rejected
Option A — Move the column to Org::Outlet
Put night_shift_start_hour directly on org_outlets. Every outlet has a value; there is no per-company default.
Why we rejected it:
- Pollutes
org_outlets(master data) with gig-domain operational settings. The table should describe what an outlet is, not how its gig settings behave. - Loses the per-company default. HR has to type the cutoff into every outlet, even when most outlets share a value.
- When a new setting is added (e.g. cancellation lead time), it would need to land on
org_outletstoo. The Org domain ends up carrying gig configuration it should not own.
Option B — Keep settings on gig_company_settings only
Accept that per-outlet variation is not supported. NTUC's 24-hour outlets have to live with the company default.
Why we rejected it:
- Does not solve the problem PR 1504 was raised to solve ("we cant assume that everyone has the same definition of day vs night"). The same logic that says "different companies have different cutoffs" also says "different outlets within one company can have different cutoffs".
- The product roadmap will keep adding per-tenant settings. Without a per-outlet table, every new setting forces the same design conversation. Pay for the pattern once.
Option C — Polymorphic key-value settings
One gig_settings table with columns scopeable_type, scopeable_id, key, value (TEXT). Each setting is a row.
Why we rejected it:
- No type safety.
valueis TEXT; every consumer parses to int/bool/etc. - Hard to query. "Get all settings for this outlet" requires polymorphic joins; "validate the type of this value" requires application-level checks.
- Used by feature-flag systems (LaunchDarkly, Unleash) where settings are heterogeneous and not known in advance. Our settings are typed and known. Over-engineered.
Option D — Cascade with NULL-as-inheritance (lazy)
The first version of this design. gig_outlet_settings columns nullable; NULL means inherit from company; resolver walks the chain at read time. Outlet rows are sparse (only created when needed).
Why we rejected it:
- NULLs make HR lazy (Reason 1 above) — nobody actively chooses values.
- NULLs make data analysis ambiguous (Reason 2) — analysts have to walk the chain or COALESCE.
- System default changes propagate silently (Reason 3) — a constants file edit changes customer behaviour without audit.
- The sparse-table logic (row exists = override) adds upsert + cleanup complexity in the Manager for marginal storage savings.
The eager+explicit pattern trades a small amount of write-time work (every outlet creation copies four values) for a large amount of clarity at read time, in analytics, and in HR workflow. This is the right trade for a platform aimed at non-technical users with a data team in the back office.
What we will build
Schema (in gig.dbml, ready for migration in PR 1585)
Modify gig_company_settings (existing table):
- Keep all four cascade columns as NOT NULL with their original defaults (
night_shift_start_hour: 18,night_shift_end_hour: 6,auto_selection_enabled: true,settlement_deadline_hour: 9). No change to column types — this is the shape that was already there before our exploration. - Update column notes to describe the eager+explicit pattern.
Add gig_outlet_settings (new table):
- Same four columns as
gig_company_settings, all NOT NULL. - No column defaults — values are written explicitly at outlet creation by
Gig::OutletSettings::CreateService. - Unique on
org_outlet_id. One row per outlet, always.
Code (in app/domains/gig/, ready for implementation in PR 1585 or follow-up)
Add Gig::Settings::DEFAULTS constant (suggested location: app/domains/gig/settings.rb):
module Gig
module Settings
DEFAULTS = {
night_shift_start_hour: 18,
night_shift_end_hour: 6,
auto_selection_enabled: true,
settlement_deadline_hour: 9
}.freeze
end
end
These are read only at row-creation time by the two create services. Consumers do not read this constant directly.
Add Gig::CompanySettings::CreateService — called by the company-creation Manager. Inserts one gig_company_settings row using Gig::Settings::DEFAULTS.
Add Gig::OutletSettings::CreateService — called by the outlet-creation Manager. Reads the parent company's gig_company_settings and copies the four column values into a new gig_outlet_settings row.
No resolver service. Consumers read directly:
# Outlet-scoped read (e.g. PayRate UI pre-fill for an outlet-specific rate):
outlet.gig_outlet_setting.night_shift_start_hour
# Company-scoped read (e.g. PayRate UI pre-fill for a company-wide rate):
company.gig_company_setting.night_shift_start_hour
No walk-up. No NULL handling. Direct association reads.
When this decision should be revisited
Revisit if any of these become true:
- Storage of
gig_outlet_settingsbecomes prohibitive at scale (e.g. customers with tens of thousands of outlets and the row count starts mattering). Switch to sparse only if storage becomes a real problem; analytics friction is usually worse. - The "no retroactive propagation" rule causes more pain than it prevents (HR repeatedly complains they have to update every outlet when the company default changes). Address with a "propagate to outlets" UI action rather than reverting to NULL-as-inheritance.
- A new setting is fundamentally dynamic (per-shift, per-rate) and does not fit either tier. At that point, put the value directly on the consumer entity (as
Gig::PayRate.starts_atdoes). The cascade pattern is for cross-cutting tenant configuration, not per-entity attributes.
What changes in the docs
| Doc | Change |
|---|---|
33-org/models/org-outlet.md | No change. Settings live in the Gig domain, not on the Outlet master data. |
35-gig/models/gig-company-setting.md | New spec. Documents the existing table reframed as the tier-1 row with eager creation + explicit values. |
35-gig/models/gig-outlet-setting.md | New spec. Documents the new tier-2 table with eager copy-from-parent at outlet creation. |
35-gig/models/gig-pay-rate.md | Updated. UC-1 references the relevant settings table (no walk). UC-5 (resolution) describes how time_type is replaced by direct band matching. |
db/gig.dbml | Updated. gig_company_settings columns stay NOT NULL with defaults (unchanged from original). New gig_outlet_settings table with NOT NULL columns and no defaults (values are seeded by the create service). |
Open questions
-
Audit history. Should
gig_company_settingsandgig_outlet_settingseach have a history table (*_setting_histories)? Recommendation: defer for low-stakes columns; add for money-adjacent ones (settlement_deadline_hour) when ops first needs to investigate a payment-timing incident. -
Propagate-to-outlets UX. Should the company-settings edit page offer a "Apply this change to all outlets that currently match the old value" button? Recommendation: defer; the current "no retroactive change" rule is the safer default. If HR asks repeatedly, add the propagate action as an explicit step (with a preview of which outlets will be affected).
-
Future levels of the tier. When Indonesia expansion lands, does Jod need a per-country level of defaults (between system and company)? Recommendation: hold off until the rollout proves the need. The pattern extends naturally — insert a
gig_country_settingsrow that the company create service reads instead of the bare constants. -
Future named time bands beyond day/night. Today the settings tables only carry
night_shift_start_hourandnight_shift_end_hourbecause the day/night split is universal (90%+ of rate variation in the legacy data fits this pattern, perstatistics/job-templates-pay-rate.md). Rare cases (NTUC's overnight stock-take, late-lunch helpers) are handled by typingstarts_at/ends_atdirectly on the PayRate row. What if customers ask for more named time bands like morning, overnight, lunch-period, and want HM to pick from a dropdown of company-defined bands instead of typing values? Three options:- Path A — Add more columns.
overnight_shift_start_hour, etc. Works if the new band is platform-universal (every customer uses it). Schema grows with each new band. - Path B — Child table.
gig_company_shift_bands(id, gig_company_setting_id, label, start_hour, end_hour)+ parallelgig_outlet_shift_bandswith eager copy at outlet creation (same pattern as today's settings tables). PayRate UI shows the company's bands as a dropdown. Schema is stable; the set of bands varies per customer. - Path C — Status quo. Settings table only holds the universal day/night. Custom bands stay on the PayRate row itself.
Recommendation: Stay on Path C until all three are true: (a) two or more customers have asked for the same non-day-night named band; (b) the set of bands varies per customer; (c) HMs would benefit from a dropdown of named bands over typing. Migration to Path B is bounded — the settings tables only seed the UI, and PayRate already stores its own bands directly, so the consumer change is localised to the rate-management form. Do not pre-build.
- Path A — Add more columns.
Next steps
- Engineer-side: PR 1585 includes the migration for
gig_outlet_settings(new) and (if needed) any column-comment refresh ongig_company_settings. The columns ongig_company_settingsthemselves do not change shape. - Engineer-side:
Gig::Settings::DEFAULTSconstant +Gig::CompanySettings::CreateService+Gig::OutletSettings::CreateService. Wire into the existing company- and outlet-creation Managers. - Doc-side: this note + the two model specs land together in the
jod-app/docsPR. - Consumer integration: PayRate creation UI pre-fills via direct read from the relevant settings row (next iteration).