Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions src/flightdeck/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,35 @@ def _patterns_must_compile(cls, patterns: list[str]) -> list[str]:
raise ValueError(f"invalid regex {pattern!r}: {exc}") from None
return patterns

@model_validator(mode="before")
@classmethod
def _merge_partial_data_rules(cls, data: object) -> object:
# A per-class override in YAML usually tightens ONE axis (e.g. pin a region);
# it must not silently drop the class's other conservative guards. Merge each
# override onto that class's conservative default, so an unspecified field
# keeps the strict default (notably forbid_training_vendors=True for
# internal/confidential/restricted) instead of reverting to DataRule's
# permissive field default. Un-governing stays possible, but only when the
# org writes it out loud (e.g. forbid_training_vendors: false) — never by
# omission. Absent classes are still filled whole by _fill_missing_classes.
if isinstance(data, dict) and isinstance(data.get("data_rules"), dict):
defaults = default_data_rules()
merged = {}
for name, override in data["data_rules"].items():
base = defaults.get(name)
if base is not None and isinstance(override, dict):
merged[name] = {**base.model_dump(), **override}
else:
merged[name] = override
data = {**data, "data_rules": merged}
return data

@model_validator(mode="after")
def _fill_missing_classes(self) -> "PolicyConfig":
# A partial data_rules block in YAML falls back to the conservative default
# per class, so overriding "restricted" never silently un-governs "internal".
# A data_rules block that omits a class entirely falls back to the
# conservative default for it, so overriding "restricted" never silently
# un-governs "internal". (Present-but-partial classes are merged onto their
# defaults in _merge_partial_data_rules, above.)
defaults = default_data_rules()
for cls, rule in defaults.items():
self.data_rules.setdefault(cls, rule) # type: ignore[arg-type]
Expand Down
25 changes: 25 additions & 0 deletions tests/test_store_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,31 @@ def test_partial_data_rules_keep_conservative_defaults(tmp_path):
rules = loaded.config.policy.data_rules
assert rules["restricted"].models == ["mock-frontier-eu"] # the override took
assert rules["internal"].forbid_training_vendors # the default survived
# …and the OVERRIDDEN class keeps its own conservative guards too: adding an
# allowlist to 'restricted' must not silently drop its no-training-vendor rule.
assert rules["restricted"].forbid_training_vendors


def test_partial_override_keeps_the_same_class_training_guard(tmp_path):
# Tightening one axis of a class (pin a region for 'internal') must not silently
# drop that class's OTHER conservative guards. Otherwise an override meant to
# TIGHTEN policy would quietly let internal data reach a training vendor.
org = dict(ORG)
org["policy"] = {"data_rules": {"internal": {"regions": ["eu"]}}}
loaded = load_org(write_org(tmp_path / "org", org=org))
rule = loaded.config.policy.data_rules["internal"]
assert rule.regions == ["eu"] # the override took
assert rule.forbid_training_vendors is True # the conservative guard survived


def test_data_rule_ungoverning_must_be_explicit(tmp_path):
# Un-governing is allowed, but only when written out loud in the org file —
# an explicit forbid_training_vendors: false is honored (and stays a visible,
# authored diff), unlike the silent loosening a bare partial override used to do.
org = dict(ORG)
org["policy"] = {"data_rules": {"internal": {"forbid_training_vendors": False}}}
loaded = load_org(write_org(tmp_path / "org", org=org))
assert loaded.config.policy.data_rules["internal"].forbid_training_vendors is False


def test_eligible_users_falls_back_to_department_headcount(org):
Expand Down
Loading