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
42 changes: 21 additions & 21 deletions reflexio/models/config_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,7 @@ def check_single_assistant_backend(self) -> Self:


class LineageGCConfig(BaseModel):
"""Configuration for the tombstone garbage-collection job (off by default).
"""Configuration for the tombstone garbage-collection job (enabled by default).

Purpose
-------
Expand All @@ -682,28 +682,28 @@ class LineageGCConfig(BaseModel):

Grace window
------------
90 days is the vetted default — cf. common 90-day soft-delete retention policies
and GDPR Art. 5(1)(e) storage-limitation. The value is a per-deployment policy
knob; ratify with your DPO before enabling in production.

Enablement gate
---------------
Enable per-org only after ALL of the following hold:

* ``tombstone_grace_window_days`` ≥ the B3 reconstruction read-back horizon, OR
B3 changelog replay is fully shipped and the horizon is confirmed. Enabling
before this point risks GC'ing tombstones that B3 replay still needs.
* DPO/product sign-off on the PII-lifetime and audit-depth implications for the
specific deployment.

Tuner floor
-----------
When the offline tuner ships, raise the effective floor to
``max(window, tuner.window_days + rollback_horizon)`` so the GC cannot delete
tombstones the tuner still needs for replay.
90 days is the default grace window. This matches common 90-day soft-delete
retention policies and satisfies GDPR Art. 5(1)(e) storage-limitation for
personal data in profiles. The value is a per-deployment policy knob; ratify
with your DPO before shortening it in production. The 90-day floor also
preserves tombstones long enough for B3 changelog replay and any rollback
horizon the offline tuner may require — raise ``tombstone_grace_window_days``
further if your replay horizon exceeds 90 days.

Enabled by default
------------------
GC is ON by default so tombstones created by the soft-delete flags (also ON by
default) are reclaimed automatically. Disabling GC while soft-delete is enabled
allows tombstone counts to grow without bound — only do this deliberately (e.g.
extended audit hold) and with a plan to re-enable.

Disabling
---------
Set ``enabled = False`` in your deployment config to hold all tombstones
indefinitely (e.g. for an extended audit window or rollback standby period).
"""

enabled: bool = False
enabled: bool = True
tombstone_grace_window_days: int = Field(default=90, gt=0)
poll_interval_seconds: int = Field(default=86400, gt=0)

Expand Down
94 changes: 70 additions & 24 deletions reflexio/server/site_var/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,61 +150,107 @@ def _is_fail_closed_flag_enabled(org_id: str, feature_key: str) -> bool:
return org_id in org_ids


def _is_default_open_flag_enabled(org_id: str, feature_key: str) -> bool:
"""
Shared default-open helper for soft-delete flags.

Returns True when the feature key is absent from config (default ON), but
preserves all explicit-disable and per-org override semantics:
- Key absent or None → True (default ON — GC is also ON by default).
- Key present but malformed (not a dict) → False with a warning (safe fallback).
- Key present, enabled=True → True for all orgs.
- Key present, enabled=False, org in enabled_org_ids → True.
- Key present, enabled=False, org NOT in enabled_org_ids → False (explicit disable).

Strict-bool and strict-list guards from _is_fail_closed_flag_enabled are
preserved: truthy strings and non-bool ints do NOT enable, and a string
enabled_org_ids does NOT match via substring (anti-#195).

Args:
org_id (str): The organization ID to check
feature_key (str): The feature flag key in the config dict

Returns:
bool: True when the flag is on (including when absent/unconfigured)
"""
config = _get_feature_flags_config()
feature_config = config.get(feature_key)

if feature_config is None:
# Key absent — default OPEN (soft-delete is on by default; GC runs too).
return True

if not isinstance(feature_config, dict):
logger.warning(
"feature_flags[%s] is not a dict (got %s), defaulting to OFF",
feature_key,
type(feature_config).__name__,
)
return False

# Strict bool identity — truthy strings like "false" must not enable (#195).
enabled = feature_config.get("enabled", False)
if enabled is True:
return True

# Reject non-list values — a string does substring `in` match, not membership (#195).
org_ids = feature_config.get("enabled_org_ids", [])
if not isinstance(org_ids, list):
return False
return org_id in org_ids


def is_dedup_soft_delete_enabled(org_id: str) -> bool:
"""
Check if deduplication soft-delete is enabled for a given organization.

This is a FAIL-CLOSED flag: if the key is absent from config or the value
is not a dict, it returns False. This is the opposite of is_feature_enabled
(which is fail-open). The difference is intentional — soft-delete must never
activate for unconfigured orgs, as tombstone growth without a GC pass would
be unbounded.
Defaults to ENABLED when the key is absent from config (default-open).
GC is also enabled by default (LineageGCConfig.enabled=True), so tombstones
created by this path are reclaimed automatically.

Explicit disable: set ``dedup_soft_delete: {enabled: false, enabled_org_ids: []}``
in the feature_flags site var to disable globally, or omit an org from
``enabled_org_ids`` while setting ``enabled: false`` to disable per-org.

A feature is enabled if:
- The feature key is absent from config (default ON), OR
- The feature's "enabled" field is True (globally enabled), OR
- The org_id is in the feature's "enabled_org_ids" list.

If the feature key is absent from config, it defaults to disabled
(fail-CLOSED). This function does NOT delegate to is_feature_enabled.

Args:
org_id (str): The organization ID to check

Returns:
bool: True only if the feature is explicitly enabled for this org
bool: True unless the feature is explicitly disabled for this org
"""
return _is_fail_closed_flag_enabled(org_id, "dedup_soft_delete")
return _is_default_open_flag_enabled(org_id, "dedup_soft_delete")


def is_aggregation_soft_delete_enabled(org_id: str) -> bool:
"""
Check if aggregation soft-delete is enabled for a given organization.

This is a FAIL-CLOSED flag: if the key is absent from config or the value
is not a dict, it returns False. This is the opposite of is_feature_enabled
(which is fail-open). The difference is intentional — soft-delete must never
activate for unconfigured orgs, as tombstone growth without a GC pass would
be unbounded.
Defaults to ENABLED when the key is absent from config (default-open).
GC is also enabled by default (LineageGCConfig.enabled=True), so SUPERSEDED
tombstones created by this path are reclaimed automatically after the 90-day
grace window.

The flag gates soft-supersede (durable replacement of hard-delete for
playbook aggregation removal). It must only be turned ON for an org once
Phase B2 GC is enabled for that org — B2 GC is the only reclaimer of the
SUPERSEDED tombstones this will later create.
Explicit disable: set ``aggregation_soft_delete: {enabled: false, enabled_org_ids: []}``
in the feature_flags site var to disable globally, or omit an org from
``enabled_org_ids`` while setting ``enabled: false`` to disable per-org.

A feature is enabled if:
- The feature key is absent from config (default ON), OR
- The feature's "enabled" field is True (globally enabled), OR
- The org_id is in the feature's "enabled_org_ids" list.

If the feature key is absent from config, it defaults to disabled
(fail-CLOSED). This function does NOT delegate to is_feature_enabled.

Args:
org_id (str): The organization ID to check

Returns:
bool: True only if the feature is explicitly enabled for this org
bool: True unless the feature is explicitly disabled for this org
"""
return _is_fail_closed_flag_enabled(org_id, "aggregation_soft_delete")
return _is_default_open_flag_enabled(org_id, "aggregation_soft_delete")


def is_resumable_extraction_agent_enabled(org_id: str) -> bool:
Expand Down
11 changes: 9 additions & 2 deletions tests/models/test_lineage_gc_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,29 @@

def test_lineage_gc_config_defaults():
cfg = LineageGCConfig()
assert cfg.enabled is False
assert cfg.enabled is True
assert cfg.tombstone_grace_window_days == 90
assert cfg.poll_interval_seconds == 86400


def test_config_has_lineage_gc_default():
cfg = Config(storage_config=StorageConfigSQLite())
assert isinstance(cfg.lineage_gc, LineageGCConfig)
assert cfg.lineage_gc.enabled is False
assert cfg.lineage_gc.enabled is True


def test_lineage_gc_enabled_can_be_set():
"""enabled=True is the default; explicitly setting it is a no-op but must still work."""
cfg = LineageGCConfig(enabled=True)
assert cfg.enabled is True


def test_lineage_gc_can_be_explicitly_disabled():
"""Explicit enabled=False must override the default-on."""
cfg = LineageGCConfig(enabled=False)
assert cfg.enabled is False


def test_lineage_gc_fields_can_be_overridden():
cfg = LineageGCConfig(tombstone_grace_window_days=30, poll_interval_seconds=3600)
assert cfg.tombstone_grace_window_days == 30
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,9 @@ def test_error_during_save_restores_archived_playbooks(self):
assert restore_by_ids_called or restore_by_name_called

def test_first_run_deletes_archived_on_success(self):
"""Regression: first-run (non-rerun) path must delete archived playbooks after success."""
"""Regression: first-run (non-rerun) path must delete archived playbooks after success (flag OFF)."""
from unittest.mock import patch

group_a = create_similar_embeddings(3, base_seed=42)
group_b = create_similar_embeddings(3, base_seed=100)
user_playbooks = create_user_playbooks_with_embeddings(group_a + group_b)
Expand All @@ -731,7 +733,9 @@ def save_agent_playbooks_side_effect(playbooks):
rerun=False,
)

aggregator.run(request)
flag_path = "reflexio.server.services.playbook.playbook_aggregator.is_aggregation_soft_delete_enabled"
with patch(flag_path, return_value=False):
aggregator.run(request)

mock_storage.delete_archived_agent_playbooks_by_playbook_name.assert_called()

Expand Down
30 changes: 23 additions & 7 deletions tests/server/services/playbook/test_playbook_aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -668,10 +668,16 @@ def test_rerun_mode_archives_all(self, mock_gen, mock_clust):
any_order=True,
)

@patch(
"reflexio.server.services.playbook.playbook_aggregator.is_aggregation_soft_delete_enabled",
return_value=False,
)
@patch.object(PlaybookAggregator, "get_clusters")
@patch.object(PlaybookAggregator, "_generate_playbooks_with_source_clusters")
def test_rerun_deletes_archived_playbooks_after_success(self, mock_gen, mock_clust):
"""After successful rerun, delete_archived_agent_playbooks_by_playbook_name is called."""
def test_rerun_deletes_archived_playbooks_after_success(
self, mock_gen, mock_clust, _mock_flag
):
"""After successful rerun (flag OFF), delete_archived_agent_playbooks_by_playbook_name is called."""
agg = self._make_runnable_aggregator()
raws = [_raw(rid=1)]
mock_clust.return_value = {0: raws}
Expand Down Expand Up @@ -731,10 +737,16 @@ def test_incremental_no_changes_updates_bookmark_only(self, mock_clust):
# Should NOT call _generate_playbooks_from_clusters
agg.storage.save_agent_playbooks.assert_not_called()

@patch(
"reflexio.server.services.playbook.playbook_aggregator.is_aggregation_soft_delete_enabled",
return_value=False,
)
@patch.object(PlaybookAggregator, "get_clusters")
@patch.object(PlaybookAggregator, "_generate_playbooks_with_source_clusters")
def test_incremental_with_changes_archives_selectively(self, mock_gen, mock_clust):
"""Incremental mode with changed clusters archives only affected playbook_ids."""
def test_incremental_with_changes_archives_selectively(
self, mock_gen, mock_clust, _mock_flag
):
"""Incremental mode (flag OFF) with changed clusters hard-deletes affected playbook_ids."""
agg = self._make_runnable_aggregator()
raws_new = [_raw(rid=5), _raw(rid=6)]
agg.storage.get_user_playbooks.return_value = raws_new
Expand Down Expand Up @@ -796,10 +808,14 @@ def test_save_exception_restores_incremental_archive(self, mock_gen, mock_clust)
[50]
)

@patch(
"reflexio.server.services.playbook.playbook_aggregator.is_aggregation_soft_delete_enabled",
return_value=False,
)
@patch.object(PlaybookAggregator, "get_clusters")
@patch.object(PlaybookAggregator, "_generate_playbooks_with_source_clusters")
def test_change_log_exception_is_caught(self, mock_gen, mock_clust):
"""Exception in add_playbook_aggregation_change_log should be caught, not raised."""
def test_change_log_exception_is_caught(self, mock_gen, mock_clust, _mock_flag):
"""Exception in add_playbook_aggregation_change_log should be caught, not raised (flag OFF)."""
agg = self._make_runnable_aggregator()
raws = [_raw(rid=1)]
mock_clust.return_value = {0: raws}
Expand All @@ -814,7 +830,7 @@ def test_change_log_exception_is_caught(self, mock_gen, mock_clust):
# Should NOT raise
agg.run(req)

# Despite the exception, delete should still proceed
# Despite the exception, hard-delete should still proceed (flag OFF path)
agg.storage.delete_archived_agent_playbooks_by_playbook_name.assert_called()

@patch.object(PlaybookAggregator, "get_clusters")
Expand Down
Loading