claude-smart 0.2.47 → 0.2.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/.codex-plugin/plugin.json +1 -1
  6. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
  7. package/plugin/dashboard/package-lock.json +61 -390
  8. package/plugin/dashboard/package.json +2 -2
  9. package/plugin/pyproject.toml +1 -1
  10. package/plugin/uv.lock +1 -1
  11. package/plugin/vendor/reflexio/.env.example +7 -0
  12. package/plugin/vendor/reflexio/pyproject.toml +2 -1
  13. package/plugin/vendor/reflexio/reflexio/README.md +8 -5
  14. package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
  15. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
  16. package/plugin/vendor/reflexio/reflexio/lib/_search.py +15 -11
  17. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
  18. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +24 -3
  19. package/plugin/vendor/reflexio/reflexio/server/README.md +25 -8
  20. package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
  21. package/plugin/vendor/reflexio/reflexio/server/api.py +133 -3273
  22. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
  23. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
  24. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
  25. package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
  26. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
  27. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
  28. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
  29. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
  30. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
  31. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
  32. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +73 -1819
  33. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +56 -4
  34. package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
  35. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
  36. package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
  37. package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
  38. package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
  39. package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
  40. package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
  41. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
  42. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
  43. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +259 -0
  44. package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
  45. package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
  46. package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
  47. package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
  48. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
  49. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
  50. package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -2
  51. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
  52. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
  53. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
  54. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
  55. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
  56. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
  57. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
  58. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
  59. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
  60. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +26 -41
  61. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +232 -123
  62. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +362 -81
  63. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -490
  64. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
  65. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
  66. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
  67. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
  68. package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
  69. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +29 -4
  70. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
  71. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +56 -351
  72. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +0 -1513
  73. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +6 -1
  74. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1281
  75. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
  76. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
  77. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
  79. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
  81. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
  82. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +132 -0
  83. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
  84. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
  85. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
  86. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
  87. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
  88. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
  89. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +6 -3
  90. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +13 -7
  91. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
  92. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +263 -0
  93. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +896 -0
  94. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
  95. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +33 -8
  96. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
  97. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
  98. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
  99. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
  100. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
  101. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
  102. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
  103. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
  104. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
  107. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
  109. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
  110. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +73 -0
  111. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +44 -86
  112. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
  113. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_governance.py +0 -148
@@ -1,22 +1,34 @@
1
- """Process-local scheduler for lineage tombstone garbage collection.
1
+ """Process-local scheduler for lineage tombstone garbage collection and expiry reclamation.
2
2
 
3
- Startup is gated on ``lineage_gc.enabled`` (default on). Each tick evaluates
4
- every org independently and hard-deletes expired tombstones per that org's
5
- ``lineage_gc`` config. One org's failure never stalls the loop; errors are
6
- captured as Sentry anomalies and the loop continues to the next org.
3
+ Startup runs when EITHER ``lineage_gc.enabled`` OR ``expiry_reclamation.enabled``
4
+ OR ``governance_retention.audit_events_retention_enabled`` is set in the bootstrap
5
+ org's config. Each tick evaluates every org independently.
7
6
 
8
- Governance-retention reclamation is a premium concern handled by the enterprise
9
- GovernanceRetentionCapability (reflexio_ext), not here.
7
+ - **Class A** (profile expiry sweep + tombstone GC): gated on ``lineage_gc.enabled``.
8
+ Hard-deletes expired tombstones per that org's ``lineage_gc`` config.
9
+ - **Class B** (plain-row direct-delete sweeps, no PII/audit obligation): gated on
10
+ ``expiry_reclamation.enabled``. Currently sweeps expired share links and expired
11
+ pending tool calls.
12
+ - **Per-org sweeps**: registered via :func:`register_per_org_sweep`; invoked
13
+ unconditionally once per org after the Class-B block. Enterprise registers a
14
+ governance-retention closure here at startup (see ``reflexio_ext``).
15
+
16
+ One org's failure never stalls the loop; errors are captured as Sentry anomalies
17
+ and the loop continues to the next org.
18
+
19
+ Note: the poll interval is always taken from ``lineage_gc.poll_interval_seconds``
20
+ even when only ``expiry_reclamation`` or ``governance_retention`` is enabled;
21
+ all sweep classes share that cadence.
10
22
  """
11
23
 
12
24
  from __future__ import annotations
13
25
 
14
26
  import logging
15
- import threading
16
27
  import time
17
28
  from collections.abc import Callable
18
29
 
19
30
  from reflexio.server.api_endpoints.request_context import RequestContext
31
+ from reflexio.server.scheduling import ThreadedScheduler
20
32
  from reflexio.server.tracing import capture_anomaly
21
33
 
22
34
  logger = logging.getLogger(__name__)
@@ -30,8 +42,94 @@ _HIGH_VOLUME_THRESHOLD = 1000
30
42
 
31
43
  _ENTITY_TYPES = ("user_playbook", "agent_playbook", "profile")
32
44
 
45
+ # Class B: direct-delete sweeps for expired plain rows (no PII/audit obligation).
46
+ # Each entry is (storage_method_name, grace_seconds, batch_limit).
47
+ # Methods added in Tasks 2.2 / 2.3; getattr-guarded so missing impls are skipped.
48
+ _CLASS_B_SWEEPS: tuple[tuple[str, int, int], ...] = (
49
+ ("delete_expired_share_links", 7 * 86400, 1000),
50
+ ("delete_expired_pending_tool_calls", 1 * 86400, 1000),
51
+ )
52
+
53
+ # Injection seam: a deployment (e.g. enterprise/managed) sets a tenant-enumerating
54
+ # provider here at startup so the single OSS-started scheduler sweeps ALL tenant
55
+ # orgs. maybe_start_lineage_gc reads this when no explicit provider is passed.
56
+ # None (OSS default) keeps behavior byte-for-byte identical to before.
57
+ _org_id_provider_hook: Callable[[], list[str]] | None = None
58
+
59
+
60
+ def set_org_id_provider(provider: Callable[[], list[str]] | None) -> None:
61
+ """Register the org-id provider consulted by ``maybe_start_lineage_gc``.
62
+
63
+ Managed multi-tenant deployments call this at app-construction time so the
64
+ lineage GC / expiry sweep reaches every tenant org (``storage.list_org_ids()``
65
+ raises ``NotImplementedError`` on Supabase, degrading to bootstrap-only).
66
+ Pass ``None`` to clear the hook (used by tests to restore OSS defaults).
67
+
68
+ Args:
69
+ provider (Callable[[], list[str]] | None): Callable returning the tenant
70
+ org ids to sweep, or ``None`` to clear.
71
+ """
72
+ global _org_id_provider_hook # noqa: PLW0603
73
+ _org_id_provider_hook = provider
74
+
75
+
76
+ # Global sweeps run once per tick (for GLOBAL tables like invitation_codes),
77
+ # gated on expiry_reclamation.enabled. Each fn takes `now` (unix epoch) and
78
+ # returns a deleted-row count. Enterprise registers its sweeps here at startup;
79
+ # empty (OSS default) keeps behavior byte-for-byte identical to before.
80
+ _global_sweep_hooks: list[Callable[[int], int]] = []
81
+
82
+
83
+ def register_global_sweep(fn: Callable[[int], int]) -> None:
84
+ """Register a global (once-per-tick) reclamation sweep.
85
+
86
+ Note: a registered sweep that absorbs its own exceptions (returns instead
87
+ of raising) will NOT trigger the generic ``lineage.global_sweep.failed``
88
+ backstop; such a sweep must emit its own failure anomaly.
89
+
90
+ Args:
91
+ fn (Callable[[int], int]): Called with the current unix epoch; returns
92
+ the number of rows it deleted.
93
+ """
94
+ _global_sweep_hooks.append(fn)
95
+
33
96
 
34
- class LineageGCScheduler:
97
+ def clear_global_sweeps() -> None:
98
+ """Clear all registered global sweeps (tests restore OSS defaults)."""
99
+ _global_sweep_hooks.clear()
100
+
101
+
102
+ # Per-org sweeps run once per org per tick (for per-org reclamation concerns).
103
+ # Each fn takes (org_id, now) and returns a deleted-row count. Enterprise
104
+ # registers its closure here at startup so governance retention folds into the
105
+ # shared scheduler loop; empty (OSS default) keeps behavior identical to before.
106
+ _per_org_sweep_hooks: list[Callable[[str, int], int]] = []
107
+
108
+
109
+ def register_per_org_sweep(fn: Callable[[str, int], int]) -> None:
110
+ """Register a per-org reclamation sweep.
111
+
112
+ The sweep is invoked once per org inside ``_gc_tick``'s org loop, after
113
+ the Class-B block, unconditionally (not gated on ``lineage_gc`` or
114
+ ``expiry_reclamation`` — the real gate lives in the enterprise closure).
115
+
116
+ Note: a registered sweep that absorbs its own exceptions (returns instead
117
+ of raising) will NOT trigger the generic ``lineage.per_org_sweep.failed``
118
+ backstop; such a sweep must emit its own failure anomaly.
119
+
120
+ Args:
121
+ fn (Callable[[str, int], int]): Called with ``(org_id, now)`` where
122
+ ``now`` is the current unix epoch; returns the number of rows deleted.
123
+ """
124
+ _per_org_sweep_hooks.append(fn)
125
+
126
+
127
+ def clear_per_org_sweeps() -> None:
128
+ """Clear all registered per-org sweeps (tests restore OSS defaults)."""
129
+ _per_org_sweep_hooks.clear()
130
+
131
+
132
+ class LineageGCScheduler(ThreadedScheduler):
35
133
  """Polling daemon that hard-deletes expired tombstones per org."""
36
134
 
37
135
  def __init__(
@@ -39,48 +137,62 @@ class LineageGCScheduler:
39
137
  *,
40
138
  request_context_factory: Callable[[str], RequestContext],
41
139
  bootstrap_org_id: str,
140
+ org_id_provider: Callable[[], list[str]] | None = None,
42
141
  ) -> None:
142
+ super().__init__(thread_name="reflexio-lineage-gc-scheduler")
43
143
  self.request_context_factory = request_context_factory
44
144
  self.bootstrap_org_id = bootstrap_org_id
45
- self._stop_event = threading.Event()
46
- self._thread: threading.Thread | None = None
145
+ # Optional injectable org-id source. When set (managed/multi-tenant mode),
146
+ # it is the authoritative enumeration seam; enterprise supplies a
147
+ # provider that lists tenant orgs via the control-plane repository — the
148
+ # only seam that surfaces managed tenants where storage.list_org_ids()
149
+ # raises NotImplementedError on Supabase. When None (OSS default),
150
+ # discovery falls back to storage.list_org_ids() exactly as before.
151
+ self.org_id_provider = org_id_provider
47
152
 
48
- def start(self) -> None:
49
- """Start the daemon thread (idempotent if already running)."""
50
- if self._thread is not None and self._thread.is_alive():
51
- return
52
- self._stop_event.clear()
53
- self._thread = threading.Thread(
54
- target=self._run_loop,
55
- name="reflexio-lineage-gc-scheduler",
56
- daemon=True,
57
- )
58
- self._thread.start()
153
+ def _on_started(self) -> None:
59
154
  logger.info("event=lineage_gc_scheduler_started")
60
155
 
61
- def stop(self, *, timeout_seconds: float = 5.0) -> None:
62
- """Signal the daemon to stop and wait for it to finish."""
63
- self._stop_event.set()
64
- if self._thread is not None:
65
- self._thread.join(timeout=timeout_seconds)
66
- self._thread = None
156
+ def _on_stopped(self) -> None:
67
157
  logger.info("event=lineage_gc_scheduler_stopped")
68
158
 
69
159
  def _discover_org_ids(self, bootstrap_ctx: RequestContext) -> list[str]:
70
- """Return every known org, always including the bootstrap org."""
71
- storage = getattr(bootstrap_ctx, "storage", None)
72
- org_ids: list[str] = []
73
- if storage is not None:
160
+ """Return every known org, always including the bootstrap org.
161
+
162
+ When an ``org_id_provider`` was injected (managed/multi-tenant mode), it
163
+ is the authoritative org-id source and ``storage.list_org_ids()`` is not
164
+ consulted. Otherwise the OSS single-tenant path is used unchanged
165
+ (``storage.list_org_ids()`` with a visible NotImplementedError fallback).
166
+ The bootstrap org is always unioned in either way.
167
+ """
168
+ if self.org_id_provider is not None:
74
169
  try:
75
- org_ids = storage.list_org_ids()
76
- except NotImplementedError:
77
- logger.warning(
78
- "event=lineage_gc_list_org_ids_not_implemented "
79
- "backend=%s bootstrap_org_id=%s — GC will only process bootstrap org",
80
- type(storage).__name__,
81
- bootstrap_ctx.org_id,
170
+ org_ids = list(self.org_id_provider())
171
+ except Exception:
172
+ capture_anomaly(
173
+ "lineage.gc.org_id_provider_failed",
174
+ bootstrap_org_id=self.bootstrap_org_id,
175
+ )
176
+ logger.exception(
177
+ "event=lineage_gc_org_id_provider_failed bootstrap_org_id=%s "
178
+ "— falling back to bootstrap org only",
179
+ self.bootstrap_org_id,
82
180
  )
83
181
  org_ids = []
182
+ else:
183
+ storage = getattr(bootstrap_ctx, "storage", None)
184
+ org_ids = []
185
+ if storage is not None:
186
+ try:
187
+ org_ids = storage.list_org_ids()
188
+ except NotImplementedError:
189
+ logger.warning(
190
+ "event=lineage_gc_list_org_ids_not_implemented "
191
+ "backend=%s bootstrap_org_id=%s — GC will only process bootstrap org",
192
+ type(storage).__name__,
193
+ bootstrap_ctx.org_id,
194
+ )
195
+ org_ids = []
84
196
  if bootstrap_ctx.org_id not in org_ids:
85
197
  org_ids = [bootstrap_ctx.org_id, *org_ids]
86
198
  return org_ids
@@ -101,58 +213,198 @@ class LineageGCScheduler:
101
213
  if ctx.storage is None:
102
214
  continue
103
215
  cfg = ctx.configurator.get_config()
104
- if not cfg.lineage_gc.enabled:
105
- continue
106
- older_than_epoch = (
107
- int(time.time())
108
- - cfg.lineage_gc.tombstone_grace_window_days * 86400
109
- )
110
- tombstone_deleted = 0
111
- for entity_type in _ENTITY_TYPES:
112
- tombstone_deleted += ctx.storage.gc_expired_tombstones(
113
- entity_type=entity_type,
114
- older_than_epoch=older_than_epoch,
216
+ except Exception:
217
+ capture_anomaly("lineage.gc.run_failed", org_id=org_id)
218
+ logger.exception("event=lineage_gc_org_failed org_id=%s", org_id)
219
+ continue
220
+
221
+ # Class A: profile expiry sweep (requires PII/grace sign-off; gated on
222
+ # lineage_gc.enabled independently of Class B).
223
+ if cfg.lineage_gc.enabled:
224
+ try:
225
+ expired_tombstoned = ctx.storage.expire_active_profiles(
226
+ now=int(time.time())
227
+ )
228
+ if expired_tombstoned:
229
+ logger.info(
230
+ "event=expiry_sweep org_id=%s profiles_tombstoned=%d",
231
+ org_id,
232
+ expired_tombstoned,
233
+ )
234
+ if expired_tombstoned > _HIGH_VOLUME_THRESHOLD:
235
+ capture_anomaly(
236
+ "lineage.expiry_sweep.high_volume",
237
+ org_id=org_id,
238
+ count=expired_tombstoned,
239
+ )
240
+ except Exception:
241
+ capture_anomaly("lineage.expiry_sweep.failed", org_id=org_id)
242
+ logger.exception(
243
+ "event=lineage_expiry_sweep_failed org_id=%s", org_id
115
244
  )
116
- if tombstone_deleted:
245
+
246
+ # Tombstone GC: each entity type is independent within the loop.
247
+ try:
248
+ older_than_epoch = (
249
+ int(time.time())
250
+ - cfg.lineage_gc.tombstone_grace_window_days * 86400
251
+ )
252
+ tombstone_deleted = 0
253
+ for entity_type in _ENTITY_TYPES:
254
+ tombstone_deleted += ctx.storage.gc_expired_tombstones(
255
+ entity_type=entity_type,
256
+ older_than_epoch=older_than_epoch,
257
+ )
258
+ if tombstone_deleted:
259
+ logger.info(
260
+ "event=lineage_gc_tick org_id=%s tombstone_deleted=%d",
261
+ org_id,
262
+ tombstone_deleted,
263
+ )
264
+ if tombstone_deleted > _HIGH_VOLUME_THRESHOLD:
265
+ capture_anomaly(
266
+ "lineage.gc.high_volume",
267
+ org_id=org_id,
268
+ count=tombstone_deleted,
269
+ )
270
+ except Exception:
271
+ capture_anomaly("lineage.gc.tombstone_gc_failed", org_id=org_id)
272
+ logger.exception(
273
+ "event=lineage_gc_tombstone_gc_failed org_id=%s", org_id
274
+ )
275
+
276
+ # Class B: direct-delete of expired plain rows (no audit/grace
277
+ # obligation; independent of lineage_gc). Each sweep is isolated so
278
+ # one failing method does not skip the rest.
279
+ if (
280
+ getattr(cfg, "expiry_reclamation", None) is not None
281
+ and cfg.expiry_reclamation.enabled
282
+ ):
283
+ now = int(time.time())
284
+ for method_name, grace, limit in _CLASS_B_SWEEPS:
285
+ method = getattr(ctx.storage, method_name, None)
286
+ if method is None:
287
+ continue
288
+ try:
289
+ deleted = method(now=now, grace_seconds=grace, limit=limit)
290
+ if deleted:
291
+ logger.info(
292
+ "event=class_b_reclaim org_id=%s method=%s deleted=%d",
293
+ org_id,
294
+ method_name,
295
+ deleted,
296
+ )
297
+ except Exception:
298
+ capture_anomaly(
299
+ "lineage.class_b_reclaim.failed",
300
+ org_id=org_id,
301
+ method=method_name,
302
+ )
303
+ logger.exception(
304
+ "event=class_b_reclaim_failed org_id=%s method=%s",
305
+ org_id,
306
+ method_name,
307
+ )
308
+
309
+ # Per-org sweeps: invoked unconditionally once per org (the real gate
310
+ # lives in each enterprise closure). Extracted to keep _gc_tick's
311
+ # cyclomatic complexity in check and to mirror _run_global_sweeps.
312
+ self._run_per_org_sweeps(org_id)
313
+
314
+ def _run_per_org_sweeps(self, org_id: str) -> None:
315
+ """Invoke each registered per-org sweep once for this org.
316
+
317
+ Invoked unconditionally (not gated on ``lineage_gc``/``expiry_reclamation``
318
+ — the real gate lives in each enterprise closure). Per-sweep failure
319
+ isolation: one sweep raising does not skip the others; a sweep that
320
+ absorbs its own exceptions bypasses the ``lineage.per_org_sweep.failed``
321
+ backstop and must emit its own anomaly.
322
+
323
+ Args:
324
+ org_id: The org being swept this tick.
325
+ """
326
+ now = int(time.time())
327
+ for sweep in _per_org_sweep_hooks:
328
+ sweep_id = getattr(sweep, "__qualname__", repr(sweep))
329
+ try:
330
+ deleted = sweep(org_id, now)
331
+ if deleted:
117
332
  logger.info(
118
- "event=lineage_gc_tick org_id=%s tombstone_deleted=%d",
333
+ "event=per_org_sweep org_id=%s sweep=%s deleted=%d",
119
334
  org_id,
120
- tombstone_deleted,
121
- )
122
- if tombstone_deleted > _HIGH_VOLUME_THRESHOLD:
123
- capture_anomaly(
124
- "lineage.gc.high_volume",
125
- org_id=org_id,
126
- count=tombstone_deleted,
335
+ sweep_id,
336
+ deleted,
127
337
  )
128
338
  except Exception:
129
- capture_anomaly("lineage.gc.run_failed", org_id=org_id)
130
- logger.exception("event=lineage_gc_org_failed org_id=%s", org_id)
339
+ capture_anomaly(
340
+ "lineage.per_org_sweep.failed",
341
+ org_id=org_id,
342
+ sweep=sweep_id,
343
+ )
344
+ logger.exception(
345
+ "event=per_org_sweep_failed org_id=%s sweep=%s",
346
+ org_id,
347
+ sweep_id,
348
+ )
349
+
350
+ def _run_global_sweeps(self, cfg: object) -> None:
351
+ """Invoke each registered global sweep once, gated on expiry_reclamation.
352
+
353
+ Per-sweep failure isolation: one sweep raising does not skip the others.
354
+ The specific per-entity anomaly is emitted inside each registered sweep;
355
+ this is a generic backstop.
131
356
 
132
- def _run_loop(self) -> None:
133
- while not self._stop_event.is_set():
134
- poll_interval = _DEFAULT_POLL_INTERVAL_SECONDS
357
+ Args:
358
+ cfg: The bootstrap-org config read for this tick.
359
+ """
360
+ expiry = getattr(cfg, "expiry_reclamation", None)
361
+ if expiry is None or not expiry.enabled:
362
+ return
363
+ now = int(time.time())
364
+ for sweep in _global_sweep_hooks:
135
365
  try:
136
- bootstrap_ctx = self.request_context_factory(self.bootstrap_org_id)
137
- cfg = bootstrap_ctx.configurator.get_config()
138
- poll_interval = cfg.lineage_gc.poll_interval_seconds
139
- org_ids = self._discover_org_ids(bootstrap_ctx)
140
- self._gc_tick(org_ids)
366
+ deleted = sweep(now)
367
+ if deleted:
368
+ logger.info("event=global_sweep deleted=%d", deleted)
141
369
  except Exception:
142
- logger.exception("event=lineage_gc_scheduler_tick_failed")
143
- self._stop_event.wait(max(poll_interval, _MIN_POLL_SECONDS))
370
+ sweep_id = getattr(sweep, "__qualname__", repr(sweep))
371
+ capture_anomaly("lineage.global_sweep.failed", sweep=sweep_id)
372
+ logger.exception("event=global_sweep_failed sweep=%s", sweep_id)
373
+
374
+ def _run_once(self) -> float:
375
+ poll_interval = _DEFAULT_POLL_INTERVAL_SECONDS
376
+ try:
377
+ bootstrap_ctx = self.request_context_factory(self.bootstrap_org_id)
378
+ cfg = bootstrap_ctx.configurator.get_config()
379
+ poll_interval = cfg.lineage_gc.poll_interval_seconds
380
+ org_ids = self._discover_org_ids(bootstrap_ctx)
381
+ self._gc_tick(org_ids)
382
+ self._run_global_sweeps(cfg)
383
+ except Exception:
384
+ logger.exception("event=lineage_gc_scheduler_tick_failed")
385
+ return max(poll_interval, _MIN_POLL_SECONDS)
144
386
 
145
387
 
146
388
  def maybe_start_lineage_gc(
147
389
  request_context_factory: Callable[[str], RequestContext],
148
390
  *,
149
391
  bootstrap_org_id: str,
392
+ org_id_provider: Callable[[], list[str]] | None = None,
150
393
  ) -> LineageGCScheduler | None:
151
- """Start the scheduler only when bootstrap config enables tombstone GC.
394
+ """Start the scheduler when bootstrap config enables tombstone GC or expiry reclamation.
395
+
396
+ Startup runs when the bootstrap-org config sets ``lineage_gc.enabled``
397
+ (Class A: profile expiry + tombstone GC), ``expiry_reclamation.enabled``
398
+ (Class B: plain-row direct-delete sweeps), or
399
+ ``governance_retention.audit_events_retention_enabled`` — OR when any sweep
400
+ hook has been registered via :func:`register_per_org_sweep` /
401
+ :func:`register_global_sweep`. Registered hooks carry their own per-org gate
402
+ in the enterprise closure, so the scheduler must start to evaluate them even
403
+ when none of the bootstrap-org flags are set. Returns ``None`` only if none
404
+ of these conditions hold.
152
405
 
153
- Startup requires bootstrap-org config to enable tombstone GC via
154
- ``lineage_gc.enabled``. Tombstone-GC enablement criteria (must
155
- ALL hold before enabling for a production org):
406
+ Tombstone-GC enablement criteria (must ALL hold before enabling ``lineage_gc``
407
+ for a production org):
156
408
 
157
409
  1. **Mechanism**: GC ages tombstones by ``retired_at`` (the INTEGER epoch
158
410
  written at every tombstone write-path). Rows with ``retired_at = NULL``
@@ -167,22 +419,31 @@ def maybe_start_lineage_gc(
167
419
  4. **DPO sign-off**: obtain sign-off on the PII-lifetime and audit-depth
168
420
  implications before enabling in any deployment that processes personal data.
169
421
 
422
+ Note: the poll cadence is always controlled by ``lineage_gc.poll_interval_seconds``
423
+ even when only ``expiry_reclamation`` is enabled; Class B shares that interval.
424
+
170
425
  Args:
171
426
  request_context_factory: Builds an org-scoped :class:`RequestContext`.
172
427
  bootstrap_org_id: Org used to read config and seed cross-org discovery.
428
+ org_id_provider: Optional tenant-enumerating org-id source. When ``None``
429
+ (OSS default), falls back to the module-level provider hook set via
430
+ :func:`set_org_id_provider`; when both are ``None`` discovery uses
431
+ ``storage.list_org_ids()`` exactly as before.
173
432
 
174
433
  Returns:
175
- LineageGCScheduler: The started scheduler, or ``None`` if not enabled.
434
+ LineageGCScheduler: The started scheduler, or ``None`` if no start
435
+ condition holds (see the startup criteria above).
176
436
  """
177
437
  try:
178
438
  ctx = request_context_factory(bootstrap_org_id)
179
439
  cfg = ctx.configurator.get_config()
180
440
 
181
441
  # Dead-knob warning: audit-event retention is an ENTERPRISE-only feature
182
- # (handled by reflexio_ext GovernanceRetentionCapability). In an OSS-only
183
- # deployment the knob is accepted but does nothing — warn loudly. Detected
184
- # via the configurator class: enterprise swaps in EnterpriseConfigurator at
185
- # construction, so the OSS DefaultConfigurator means "no enterprise here".
442
+ # (handled by an enterprise per-org reclamation sweep registered via
443
+ # register_per_org_sweep). In an OSS-only deployment the knob is accepted
444
+ # but does nothing — warn loudly. Detected via the configurator class:
445
+ # enterprise swaps in EnterpriseConfigurator at construction, so the OSS
446
+ # DefaultConfigurator means "no enterprise here".
186
447
  from reflexio.server.services.configurator.configurator import ( # noqa: PLC0415
187
448
  DefaultConfigurator,
188
449
  get_configurator_class,
@@ -201,7 +462,24 @@ def maybe_start_lineage_gc(
201
462
  "and will NOT run here."
202
463
  )
203
464
 
204
- if not cfg.lineage_gc.enabled:
465
+ expiry_reclamation_enabled = getattr(
466
+ getattr(cfg, "expiry_reclamation", None), "enabled", False
467
+ )
468
+ governance_retention_enabled = getattr(
469
+ gr, "audit_events_retention_enabled", False
470
+ )
471
+ # Also start when any reclamation sweep has been registered — registered
472
+ # hooks carry their own per-org gates (in the enterprise closure), so the
473
+ # scheduler must run even if the bootstrap org's config has all flags off.
474
+ # This preserves the "start unconditionally, gate per-org" invariant of
475
+ # the deleted GovernanceRetentionScheduler.
476
+ if not (
477
+ cfg.lineage_gc.enabled
478
+ or expiry_reclamation_enabled
479
+ or governance_retention_enabled
480
+ or bool(_per_org_sweep_hooks)
481
+ or bool(_global_sweep_hooks)
482
+ ):
205
483
  return None
206
484
  except Exception as exc:
207
485
  logger.warning(
@@ -214,6 +492,9 @@ def maybe_start_lineage_gc(
214
492
  scheduler = LineageGCScheduler(
215
493
  request_context_factory=request_context_factory,
216
494
  bootstrap_org_id=bootstrap_org_id,
495
+ org_id_provider=(
496
+ org_id_provider if org_id_provider is not None else _org_id_provider_hook
497
+ ),
217
498
  )
218
499
  scheduler.start()
219
500
  return scheduler