Skip to content

Lineage Phase B1 — complete instrumentation (OSS/SQLite): update/hard_delete/status_change + reflection supersede + op=aggregate#187

Merged
yilu331 merged 17 commits into
mainfrom
feat/lineage-phase-b1
Jun 20, 2026
Merged

Lineage Phase B1 — complete instrumentation (OSS/SQLite): update/hard_delete/status_change + reflection supersede + op=aggregate#187
yilu331 merged 17 commits into
mainfrom
feat/lineage-phase-b1

Conversation

@yilu331

@yilu331 yilu331 commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Lineage Phase B1 (OSS / SQLite) — completes the content-free lineage instrumentation begun in Phase A so lineage_event becomes the complete, single audit source for every state-changing write to playbooks/profiles. Each mutation emits its event atomically (one with self._lock: + one commit), keyed on the 5-column idempotency key (org_id, entity_type, entity_id, op, request_id).

Changes

  • In-place update_* (update_user_playbook, update_agent_playbook, update_agent_playbook_status, update_user_profile_by_id) → emit op=revise (content changed) or op=status_change, per-call UUID request_id.
  • hard_delete completenessdelete_profiles_by_ids (+ emit_hard_delete param), single-row deletes, and the bulk / GDPR org-wipe paths (delete_all_*, delete_all_profiles_for_user, delete_*_by_playbook_name) now emit a per-id hard_delete before the physical delete (SELECT-inside-lock for atomicity). PENDING-rerun purge stays a no-event carve-out.
  • status_change on archive + bulk status-flip paths, with accurate per-row reason ({prior}->archived).
  • Reflection profile edit routed through supersede_record (old profile → SUPERSEDED tombstone + superseded_by + revise event; lost-CAS rollback uses emit_hard_delete=False). Closes a Phase A gap.
  • op=aggregate set-level events on user→agent aggregation (W3C PROV wasDerivedFrom, M:N), best-effort (capture_anomaly, never aborts aggregation).
  • resolve_current emits a Sentry capture_anomaly on cycle / max-hops (previously a silent None).
  • Idempotency key extended to include entity_type (prevents cross-entity-type collisions) and org_id.
  • Storage contract cases for the B1 ops (inherited by enterprise backends).

Test Plan

  • New integration tests per task (update/hard_delete/status_change/reflection-supersede/aggregate) + contract cases; ~187 storage integration tests pass, 0 regressions.
  • Best-effort aggregate path has a regression test (failing append doesn't abort the run, capture_anomaly fires).
  • Reflection request_id pinned end-to-end on the revise event.

Built via subagent-driven-development (per-task TDD + review + fix loop); final whole-branch review passed.

Summary by CodeRabbit

  • New Features
    • Added richer lineage events with optional status transition fields (from/to/status namespace).
    • Expanded audit/lineage emission across playbook and profile lifecycle operations, including per-entity hard deletes, per-row status changes, in-place revise tracking, and improved playbook aggregation lineage (best-effort).
    • Updated profile supersede/reflection flow to consistently attach and propagate request ids.
  • Bug Fixes
    • Lineage events now better match actual successful mutations; aggregation lineage append failures no longer break the run.
  • Documentation
    • Added guidance on SQLite lineage/FTS/vector atomic write behavior.
  • Tests
    • Added/expanded integration and unit tests covering lineage contracts, aggregation/reflection behavior, and request-id uniqueness.

yilu331 added 11 commits June 20, 2026 08:57
…s — SQLite (delete_profiles_by_ids + single-row + bulk/GDPR wipe paths)
…3), SELECT-in-lock for bulk wipes, dedupe emit helper
… (closes Phase A gap)

- Replace archive_profile_by_id in _replace_profile with supersede_record
- Cited profile becomes SUPERSEDED with superseded_by pointer + revise lineage event
- CAS race lost: delete just-added successor (emit_hard_delete=False) to avoid orphan
- Add test_reflection_profile_supersede_integration.py: asserts SUPERSEDED status,
  superseded_by pointer, and revise event after a reflection profile revision
- Update test_reflection_service.py: TestReplaceProfile now checks Status.SUPERSEDED
  with superseded_by pointer; TestPerDecisionMalformed checks SUPERSEDED not ARCHIVED;
  TestArchiveAfterInsertFailure updated to patch supersede_record and assert new semantics
@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds lineage event emission (hard_delete, status_change, revise, aggregate) to all SQLite storage mutation methods for playbooks and profiles. Threads request_id through the reflection pipeline and switches _replace_profile from archive_profile_by_id to a CAS-guarded supersede_record with LineageContext. Adds aggregate lineage emission in PlaybookAggregator with best-effort error handling. All changes are backed by new integration test modules and architecture guardrail documentation.

Changes

Lineage event emission: storage, reflection, and aggregation

Layer / File(s) Summary
Lineage event schema extension
reflexio/models/api_schema/domain/entities.py, reflexio/server/services/storage/sqlite_storage/_base.py, reflexio/server/services/storage/sqlite_storage/_lineage.py
LineageEvent model gains from_status, to_status, and status_namespace optional fields; lineage_event table schema is extended via CREATE TABLE and ALTER TABLE migrations; _append_event_stmt and get_lineage_events persist and retrieve the new fields.
request_id field in ReflectionServiceRequest and GenerationService wiring
reflexio/server/services/reflection/reflection_service_utils.py, reflexio/server/services/generation_service.py, tests/server/services/reflection/test_reflection_service_utils.py
ReflectionServiceRequest gains a request_id: str field with UUID-hex default factory; _maybe_run_reflection is extended to accept and forward it into the request object; a unit test verifies unique id generation.
Reflection service: archive→CAS supersede with LineageContext
reflexio/server/services/reflection/reflection_service.py
_replace_profile imports LineageContext, replaces archive_profile_by_id with storage.supersede_record (CAS-guarded); on CAS-race loss the inserted successor is deleted with emit_hard_delete=False.
Abstract storage interface: emit_hard_delete flag
reflexio/server/services/storage/storage_base/_profiles.py
delete_profiles_by_ids abstract method gains a keyword-only emit_hard_delete: bool = True parameter with documented suppression semantics for rollback paths.
SQLite playbook storage: lineage helpers and mutation emissions
reflexio/server/services/storage/sqlite_storage/_playbook.py
Adds _emit_hard_delete_playbook helper; reworks all user/agent playbook delete, archive, and update methods to emit hard_delete, status_change, or revise lineage events per affected row under batch and fresh request IDs.
SQLite profile storage: lineage helpers and mutation emissions
reflexio/server/services/storage/sqlite_storage/_profiles.py
Adds _emit_hard_delete_profile helper; refactors profile update, deletion, status-flip, archive, and delete_profiles_by_ids methods to emit per-row lineage events under _lock with fresh uuid request IDs.
PlaybookAggregator: aggregate lineage event with best-effort anomaly handling
reflexio/server/services/playbook/playbook_aggregator.py
After saving agent playbooks, emits a LineageEvent with op="aggregate" and wasDerivedFrom source user playbook IDs; append_lineage_event failures are caught, logged, and reported via capture_anomaly.
Aggregation lineage integration tests
tests/server/services/playbook/test_aggregation_lineage_integration.py
Two integration tests: one asserts a single op="aggregate" lineage event with correct source_ids and request_id; the other verifies that append_lineage_event failures are best-effort (aggregation completes, capture_anomaly is invoked).
Reflection supersede integration and unit test updates
tests/server/services/reflection/test_reflection_profile_supersede_integration.py, tests/server/services/reflection/test_reflection_service.py
New integration tests asserting SUPERSEDED status, superseded_by pointer, and revise lineage events; existing unit tests updated from archive to supersede/CAS-loss assertions.
Storage lineage contract and B1 integration tests
tests/server/services/storage/test_lineage_contract.py, tests/server/services/storage/test_lineage_b1_harddelete_integration.py, tests/server/services/storage/test_lineage_b1_statuschange_integration.py, tests/server/services/storage/test_lineage_b1_update_integration.py
Contract cases and three new integration test modules covering hard_delete emission for single/bulk/GDPR deletes, status_change emission for archive/status-flip methods, and revise/status_change emission for in-place update methods.
Architecture guardrails: SQLite lineage and atomicity
.claude/rules/reflexio-patterns.md
Documents SQLite lineage-event/FTS/vec write atomicity rules: shared connection commit scope, self-committing helper ordering, lineage event emission timing/gating, and scope alignment requirements.

Sequence Diagram(s)

sequenceDiagram
  participant GenerationService
  participant ReflectionServiceRequest
  participant ReflectionService
  participant storage

  GenerationService->>GenerationService: generate request_id
  GenerationService->>ReflectionService: _maybe_run_reflection(..., request_id)
  ReflectionService->>ReflectionServiceRequest: construct(request_id=request_id)
  ReflectionService->>storage: insert successor profile
  ReflectionService->>storage: supersede_record(incumbent_id, successor_id, LineageContext(op="revise", request_id))
  alt CAS success
    storage-->>ReflectionService: True — incumbent becomes SUPERSEDED
  else CAS race lost
    storage-->>ReflectionService: False
    ReflectionService->>storage: delete_profiles_by_ids(successor_id, emit_hard_delete=False)
  end
Loading
sequenceDiagram
  participant PlaybookAggregator
  participant SQLiteStorage

  PlaybookAggregator->>SQLiteStorage: save agent playbook(s)
  PlaybookAggregator->>SQLiteStorage: append_lineage_event(op="aggregate", prov="wasDerivedFrom", source_ids=[user_pb_ids])
  alt success
    SQLiteStorage-->>PlaybookAggregator: ok
  else append raises
    PlaybookAggregator->>PlaybookAggregator: log warning + capture_anomaly("lineage.aggregate.append_failed")
    PlaybookAggregator-->>PlaybookAggregator: continue (best-effort)
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • ReflexioAI/reflexio#39: Modifies GenerationService._maybe_run_reflection and ReflectionServiceRequest/reflection_service behavior directly — the PR's request_id lineage propagation builds on that reflection pipeline wiring.
  • ReflexioAI/reflexio#181: Introduced the phase-A lineage primitives (supersede_record, LineageContext, append_lineage_event) that this PR threads through reflection and storage mutation methods.

Poem

🐇 Hop hop through the audit trail,
Every delete leaves a tale!
hard_delete, revise, status_change too,
CAS guards keep the lineage true.
No phantom erasures shall prevail —
The rabbit's logs will never fail! 📜

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: completing Phase B1 lineage instrumentation with comprehensive coverage of update, hard_delete, status_change operations, reflection supersede behavior, and aggregate events across SQLite/OSS.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/lineage-phase-b1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
reflexio/server/services/storage/sqlite_storage/_profiles.py (1)

286-304: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Scope the profile sidecar cleanup and audit event to the row being deleted.

rowid_row is selected by profile_id only, then the method emits hard_delete and deletes FTS/vec sidecars before the DELETE ... WHERE user_id = ? AND profile_id = ? confirms a row was removed. A mismatched user_id can therefore erase another profile’s search sidecars and record a phantom delete. Select rowid with both user_id and profile_id inside _lock, and return without emitting if no row matches.

Suggested shape
-        rowid_row = self._fetchone(
-            "SELECT rowid FROM profiles WHERE profile_id = ?",
-            (request.profile_id,),
-        )
         with self._lock:
+            rowid_row = self.conn.execute(
+                "SELECT rowid FROM profiles WHERE user_id = ? AND profile_id = ?",
+                (request.user_id, request.profile_id),
+            ).fetchone()
+            if not rowid_row:
+                return
             _emit_hard_delete_profile(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/services/storage/sqlite_storage/_profiles.py` around lines
286 - 304, The rowid_row selection uses only profile_id outside the lock, then
the hard delete audit event and FTS/vector sidecar cleanup operations occur
inside the lock before the actual DELETE statement which validates both user_id
and profile_id. This creates a security issue where a mismatched user_id can
cause sidecars to be deleted for a profile that shouldn't be deleted. Move the
rowid_row selection inside the _lock block and update its WHERE clause to
include both user_id and profile_id conditions. Check if rowid_row is found
after this selection, and only execute _emit_hard_delete_profile,
_fts_delete_profile, and _vec_delete if a matching row exists, returning early
if no row matches both conditions.
🧹 Nitpick comments (3)
tests/server/services/storage/test_lineage_b1_update_integration.py (1)

45-54: ⚡ Quick win

Pin request_id uniqueness across sequential updates in this test.

Since the intent is “distinct event per call,” also assert the two revise events have different request_id values. That directly validates the per-call UUID guarantee behind idempotency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/server/services/storage/test_lineage_b1_update_integration.py` around
lines 45 - 54, The test_update_user_playbook_multiple_edits_each_produce_event
function verifies that multiple updates produce distinct revise events, but it
does not validate that each event has a unique request_id value. Add an
assertion after checking the operation types that compares the request_id values
of the two revise events (accessing them via e.request_id from the events
returned by get_lineage_events) to ensure they are different, which directly
validates the per-call UUID guarantee for idempotency.
tests/server/services/storage/test_lineage_b1_harddelete_integration.py (1)

107-121: ⚡ Quick win

Assert shared request_id across the same bulk delete call.

This test checks one hard_delete per id, but not that both events share the same batch request_id. Adding that assertion would better lock the B1 idempotency contract for multi-row operations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/server/services/storage/test_lineage_b1_harddelete_integration.py`
around lines 107 - 121, The test function
test_delete_profiles_by_ids_multiple_emits_one_event_per_id currently validates
that each profile deletion generates one hard_delete event, but does not verify
that both events share the same request_id. Add assertions after the existing
length assertions to extract the request_id from the hard_delete event in
events_a and events_b, and assert that they are equal. This ensures that
multiple profiles deleted in the same bulk operation are tracked with a shared
request_id for the B1 idempotency contract.
tests/server/services/storage/test_lineage_b1_statuschange_integration.py (1)

72-90: ⚡ Quick win

Also verify batch request_id coherence for status_change events.

You already validate one event per id; add an assertion that both emitted events from this single archive call carry the same request_id (and are non-empty). That strengthens contract coverage for lineage batching.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/server/services/storage/test_lineage_b1_statuschange_integration.py`
around lines 72 - 90, The test
test_archive_agent_playbooks_by_ids_emits_one_event_per_id validates that one
event per id is emitted but does not verify request_id coherence across the
batch. Enhance the test by adding an assertion after the existing event count
validation to confirm that all status_change events from both ids carry the same
non-empty request_id. Collect all status_change events from both apids and
verify they share an identical request_id value that is not empty or None,
ensuring the lineage batching contract is properly covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@reflexio/server/services/reflection/reflection_service_utils.py`:
- Line 43: The request_id parameter has a default value of empty string, which
allows callers to omit it and unintentionally emit revise events with unlinked
lineage. Remove the default value assignment from the request_id parameter
definition (change from `request_id: str = ""` to just `request_id: str`) to
make it a required parameter, ensuring all callers must explicitly provide a
non-empty request_id when creating LineageContext instances.

In `@reflexio/server/services/storage/sqlite_storage/_playbook.py`:
- Around line 329-344: The delete_user_playbook method emits a hard_delete audit
event before verifying the row actually exists, which creates phantom deletion
records for stale or already-deleted IDs. Before calling
_emit_hard_delete_playbook, first query the database to check if a row with the
given user_playbook_id exists in user_playbooks, then only emit the hard_delete
event if the row is found. If no row exists, skip the _emit_hard_delete_playbook
call entirely to avoid recording false deletion events. Apply the same fix to
the other by-id delete methods referenced at lines 386-395, 853-868, and
911-920.
- Around line 947-964: The lineage event is being appended even when the UPDATE
statement affects zero rows due to a race condition where another writer may
delete the playbook between the existence check and the UPDATE. Capture the
cursor object returned by the self.conn.execute() call for the UPDATE statement,
and wrap the _append_event_stmt call in a conditional that only executes when
the cursor's rowcount property is greater than zero. Apply this same fix to all
three locations mentioned: the current block in the diff and also in the other
two similar update methods at the specified line ranges.
- Around line 324-325: The bulk playbook deletion logic in the hard-delete/GDPR
paths deletes from user_playbooks_fts and user_playbooks tables but leaves
orphaned vector sidecar entries in user_playbooks_vec and agent_playbooks_vec
tables. Add DELETE statements for both vector sidecar tables (user_playbooks_vec
and agent_playbooks_vec) in the same locked transaction, executing these deletes
before removing the base playbook rows. Apply this fix to all bulk delete
locations mentioned in the comment: the main deletion block around lines 324-325
and all other occurrences at lines 371-375, 397-403, 848-849, 896-900, 922-928,
and 1464-1468 to ensure vector embeddings are properly cleaned up during
hard-deletes.
- Around line 1094-1122: The archive operation is emitting false status_change
events for playbooks that are already archived (showing archived->archived when
no state actually changed). Modify the WHERE clause construction in the archive
query to exclude not only APPROVED playbooks but also playbooks that already
have status set to 'archived', so that only real state transitions trigger event
emissions. Update the WHERE clause string and add appropriate parameters to
filter out both statuses.

In `@reflexio/server/services/storage/sqlite_storage/_profiles.py`:
- Around line 306-346: The delete_all_profiles_for_user and delete_all_profiles
methods are removing rows from the profiles and profiles_fts tables but not
cleaning up the corresponding vector embeddings stored in the profiles_vec
table. Since these are hard-delete operations that may include GDPR/user-erasure
flows, the vector sidecar data must be removed in the same locked transaction.
Add deletion statements to remove rows from profiles_vec: in
delete_all_profiles_for_user, delete from profiles_vec for all profile IDs in
the pids list, and in delete_all_profiles, delete all rows from profiles_vec.
Execute these deletions before calling conn.commit() to ensure they are part of
the same atomic transaction as the base deletions.
- Around line 221-260: The revise event is being appended unconditionally after
the UPDATE statement, even if no rows were actually updated because the profile
may have been deleted or superseded. Capture the cursor object returned from the
self.conn.execute() call that performs the UPDATE, and wrap the
_append_event_stmt() function call in a conditional block that only executes
when cur.rowcount is greater than 0. This ensures the lineage event is only
recorded when the profile update actually succeeds in modifying a row.
- Around line 527-539: The `delete_profiles_by_ids` method currently emits hard
delete events and deletes for all requested profile IDs without verifying they
exist in the database first. Before calling `_emit_hard_delete_profile` and
performing the actual deletion, query the profiles table to select only the
profile IDs that actually exist from the requested list (using a SELECT query
with the same IN clause within the `_lock` context). Then iterate over only the
filtered set of existing profile IDs when emitting hard delete events via
`_emit_hard_delete_profile` and when deleting via `_fts_delete_profile`. If no
profiles exist in the requested set, return 0 without emitting any audit events
or performing unnecessary delete operations.
- Around line 456-476: The archive_profile_by_id method only commits the
database transaction when cur.rowcount > 0, which means no-op archive attempts
(when the profile is already archived or doesn't exist) leave the connection in
an open write transaction. Move self.conn.commit() outside the if cur.rowcount >
0 block so it executes regardless of whether rows were updated, while keeping
the _append_event_stmt call inside the conditional since events should only be
emitted when the status actually changes.

---

Outside diff comments:
In `@reflexio/server/services/storage/sqlite_storage/_profiles.py`:
- Around line 286-304: The rowid_row selection uses only profile_id outside the
lock, then the hard delete audit event and FTS/vector sidecar cleanup operations
occur inside the lock before the actual DELETE statement which validates both
user_id and profile_id. This creates a security issue where a mismatched user_id
can cause sidecars to be deleted for a profile that shouldn't be deleted. Move
the rowid_row selection inside the _lock block and update its WHERE clause to
include both user_id and profile_id conditions. Check if rowid_row is found
after this selection, and only execute _emit_hard_delete_profile,
_fts_delete_profile, and _vec_delete if a matching row exists, returning early
if no row matches both conditions.

---

Nitpick comments:
In `@tests/server/services/storage/test_lineage_b1_harddelete_integration.py`:
- Around line 107-121: The test function
test_delete_profiles_by_ids_multiple_emits_one_event_per_id currently validates
that each profile deletion generates one hard_delete event, but does not verify
that both events share the same request_id. Add assertions after the existing
length assertions to extract the request_id from the hard_delete event in
events_a and events_b, and assert that they are equal. This ensures that
multiple profiles deleted in the same bulk operation are tracked with a shared
request_id for the B1 idempotency contract.

In `@tests/server/services/storage/test_lineage_b1_statuschange_integration.py`:
- Around line 72-90: The test
test_archive_agent_playbooks_by_ids_emits_one_event_per_id validates that one
event per id is emitted but does not verify request_id coherence across the
batch. Enhance the test by adding an assertion after the existing event count
validation to confirm that all status_change events from both ids carry the same
non-empty request_id. Collect all status_change events from both apids and
verify they share an identical request_id value that is not empty or None,
ensuring the lineage batching contract is properly covered.

In `@tests/server/services/storage/test_lineage_b1_update_integration.py`:
- Around line 45-54: The
test_update_user_playbook_multiple_edits_each_produce_event function verifies
that multiple updates produce distinct revise events, but it does not validate
that each event has a unique request_id value. Add an assertion after checking
the operation types that compares the request_id values of the two revise events
(accessing them via e.request_id from the events returned by get_lineage_events)
to ensure they are different, which directly validates the per-call UUID
guarantee for idempotency.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ad3eafa5-801c-400d-8d3f-d9225c34133d

📥 Commits

Reviewing files that changed from the base of the PR and between 420533a and 0e3f088.

📒 Files selected for processing (14)
  • reflexio/server/services/generation_service.py
  • reflexio/server/services/playbook/playbook_aggregator.py
  • reflexio/server/services/reflection/reflection_service.py
  • reflexio/server/services/reflection/reflection_service_utils.py
  • reflexio/server/services/storage/sqlite_storage/_playbook.py
  • reflexio/server/services/storage/sqlite_storage/_profiles.py
  • reflexio/server/services/storage/storage_base/_profiles.py
  • tests/server/services/playbook/test_aggregation_lineage_integration.py
  • tests/server/services/reflection/test_reflection_profile_supersede_integration.py
  • tests/server/services/reflection/test_reflection_service.py
  • tests/server/services/storage/test_lineage_b1_harddelete_integration.py
  • tests/server/services/storage/test_lineage_b1_statuschange_integration.py
  • tests/server/services/storage/test_lineage_b1_update_integration.py
  • tests/server/services/storage/test_lineage_contract.py

Comment thread reflexio/server/services/reflection/reflection_service_utils.py Outdated
Comment thread reflexio/server/services/storage/sqlite_storage/_playbook.py Outdated
Comment thread reflexio/server/services/storage/sqlite_storage/_playbook.py
Comment thread reflexio/server/services/storage/sqlite_storage/_playbook.py
Comment thread reflexio/server/services/storage/sqlite_storage/_playbook.py Outdated
Comment thread reflexio/server/services/storage/sqlite_storage/_profiles.py
Comment on lines 306 to 346
@SQLiteStorageBase.handle_exceptions
def delete_all_profiles_for_user(self, user_id: str) -> None:
pids = [
r["profile_id"]
for r in self._fetchall(
"SELECT profile_id FROM profiles WHERE user_id = ?", (user_id,)
)
]
for pid in pids:
self._fts_delete_profile(pid)
self._execute("DELETE FROM profiles WHERE user_id = ?", (user_id,))
batch_request_id = uuid.uuid4().hex
with self._lock:
pids = [
r["profile_id"]
for r in self.conn.execute(
"SELECT profile_id FROM profiles WHERE user_id = ?", (user_id,)
).fetchall()
]
if not pids:
return
for pid in pids:
_emit_hard_delete_profile(
self.conn,
org_id=self.org_id,
entity_id=str(pid),
request_id=batch_request_id,
)
self._fts_delete_profile(pid)
self.conn.execute("DELETE FROM profiles WHERE user_id = ?", (user_id,))
self.conn.commit()

@SQLiteStorageBase.handle_exceptions
def delete_all_profiles(self) -> None:
batch_request_id = uuid.uuid4().hex
with self._lock:
pids = [
r["profile_id"]
for r in self.conn.execute("SELECT profile_id FROM profiles").fetchall()
]
for pid in pids:
_emit_hard_delete_profile(
self.conn,
org_id=self.org_id,
entity_id=str(pid),
request_id=batch_request_id,
)
self.conn.execute("DELETE FROM profiles_fts")
self.conn.execute("DELETE FROM profiles")
self.conn.commit()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove profiles_vec rows during profile hard deletes.

These bulk delete paths clean FTS/base rows but leave profile embeddings in profiles_vec. Since hard-delete paths include GDPR/user-erasure flows, the vector sidecar needs to be removed in the same locked transaction as the base delete.

Suggested shape
             for pid in pids:
                 _emit_hard_delete_profile(
                     self.conn,
                     org_id=self.org_id,
                     entity_id=str(pid),
                     request_id=batch_request_id,
                 )
-                self._fts_delete_profile(pid)
+            rows = self.conn.execute(
+                f"SELECT rowid FROM profiles WHERE profile_id IN ({ph})", pids
+            ).fetchall()
+            rowids = [r["rowid"] for r in rows]
+            self.conn.execute(f"DELETE FROM profiles_fts WHERE profile_id IN ({ph})", pids)
+            if self._has_sqlite_vec and rowids:
+                rowid_ph = ",".join("?" for _ in rowids)
+                self.conn.execute(
+                    f"DELETE FROM profiles_vec WHERE rowid IN ({rowid_ph})", rowids
+                )

Also applies to: 478-502, 519-540

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/services/storage/sqlite_storage/_profiles.py` around lines
306 - 346, The delete_all_profiles_for_user and delete_all_profiles methods are
removing rows from the profiles and profiles_fts tables but not cleaning up the
corresponding vector embeddings stored in the profiles_vec table. Since these
are hard-delete operations that may include GDPR/user-erasure flows, the vector
sidecar data must be removed in the same locked transaction. Add deletion
statements to remove rows from profiles_vec: in delete_all_profiles_for_user,
delete from profiles_vec for all profile IDs in the pids list, and in
delete_all_profiles, delete all rows from profiles_vec. Execute these deletions
before calling conn.commit() to ensure they are part of the same atomic
transaction as the base deletions.

Comment thread reflexio/server/services/storage/sqlite_storage/_profiles.py
Comment thread reflexio/server/services/storage/sqlite_storage/_profiles.py Outdated
- emit hard_delete/status_change ONLY for rows actually mutated (rowcount>0); user-scoped
  existence check in delete_user_profile (no cross-user phantom erasure events)
- update_agent_playbook_status: rowcount guard + real old->new reason
- archive_profile_by_id: commit unconditionally (no-op txn leak fix)
- delete_all_user_playbooks_by_status: enforce PENDING-only carve-out (raise otherwise)
- actor=system preserved on delete_*_by_ids; ReflectionServiceRequest.request_id default_factory(uuid)
- ruff lint + pyright pragma; phantom-event negative tests added

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@tests/server/services/reflection/test_reflection_profile_supersede_integration.py`:
- Line 258: The call to _set_config at line 258 is incorrectly passing the
configuration as a nested dictionary with the key reflection_config instead of
unpacking the dictionary fields directly. Since _set_config unpacks **kwargs
into ReflectionConfig(**kwargs), the dictionary fields should be passed as
individual keyword arguments. Change the call from _set_config(request_context,
reflection_config={"post_horizon_size": 0}) to _set_config(request_context,
post_horizon_size=0) so that the ReflectionConfig constructor receives the
actual field name it expects rather than an unexpected reflection_config
parameter.

In `@tests/server/services/storage/test_lineage_b1_harddelete_integration.py`:
- Around line 310-320: The tests
test_delete_all_user_playbooks_by_status_archived_raises and
test_delete_all_user_playbooks_by_status_merged_raises expect a StorageError to
be raised, but the delete_all_user_playbooks_by_status method implementation
currently raises ValueError when the status is not Status.PENDING. Update the
implementation to raise StorageError instead of ValueError when the status
validation check fails, ensuring the exception type matches what the tests
expect.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d031f0db-c204-4cda-a588-d6c799fb28f8

📥 Commits

Reviewing files that changed from the base of the PR and between 0e3f088 and 5c02653.

📒 Files selected for processing (10)
  • reflexio/server/services/playbook/playbook_aggregator.py
  • reflexio/server/services/reflection/reflection_service.py
  • reflexio/server/services/reflection/reflection_service_utils.py
  • reflexio/server/services/storage/sqlite_storage/_playbook.py
  • reflexio/server/services/storage/sqlite_storage/_profiles.py
  • tests/server/services/reflection/test_reflection_profile_supersede_integration.py
  • tests/server/services/reflection/test_reflection_service_utils.py
  • tests/server/services/storage/test_lineage_b1_harddelete_integration.py
  • tests/server/services/storage/test_lineage_b1_statuschange_integration.py
  • tests/server/services/storage/test_lineage_b1_update_integration.py
🚧 Files skipped from review as they are similar to previous changes (5)
  • tests/server/services/storage/test_lineage_b1_update_integration.py
  • tests/server/services/storage/test_lineage_b1_statuschange_integration.py
  • reflexio/server/services/playbook/playbook_aggregator.py
  • reflexio/server/services/reflection/reflection_service.py
  • reflexio/server/services/storage/sqlite_storage/_playbook.py

"""
# Use post_horizon_size=0 to disable the horizon filter so both passes can
# proceed regardless of how many follow-up interactions exist.
_set_config(request_context, reflection_config={"post_horizon_size": 0})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Fix incorrect kwargs passing to _set_config.

The _set_config helper unpacks **kwargs directly into ReflectionConfig(**kwargs) (line 88). Passing reflection_config={"post_horizon_size": 0} will attempt to construct ReflectionConfig(reflection_config=...), which will fail because ReflectionConfig does not have a reflection_config parameter.

The correct call should pass the field directly:

🐛 Proposed fix
-        _set_config(request_context, reflection_config={"post_horizon_size": 0})
+        _set_config(request_context, post_horizon_size=0)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_set_config(request_context, reflection_config={"post_horizon_size": 0})
_set_config(request_context, post_horizon_size=0)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@tests/server/services/reflection/test_reflection_profile_supersede_integration.py`
at line 258, The call to _set_config at line 258 is incorrectly passing the
configuration as a nested dictionary with the key reflection_config instead of
unpacking the dictionary fields directly. Since _set_config unpacks **kwargs
into ReflectionConfig(**kwargs), the dictionary fields should be passed as
individual keyword arguments. Change the call from _set_config(request_context,
reflection_config={"post_horizon_size": 0}) to _set_config(request_context,
post_horizon_size=0) so that the ReflectionConfig constructor receives the
actual field name it expects rather than an unexpected reflection_config
parameter.

Comment on lines +310 to +320
def test_delete_all_user_playbooks_by_status_archived_raises(tmp_path):
s = _store(tmp_path)
with pytest.raises(StorageError, match="PENDING"):
s.delete_all_user_playbooks_by_status(Status.ARCHIVED)


def test_delete_all_user_playbooks_by_status_merged_raises(tmp_path):
s = _store(tmp_path)
with pytest.raises(StorageError, match="PENDING"):
s.delete_all_user_playbooks_by_status(Status.MERGED)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Tests expect StorageError but implementation raises ValueError.

The implementation of delete_all_user_playbooks_by_status raises ValueError when status is not PENDING:

if status != Status.PENDING:
    raise ValueError(
        f"delete_all_user_playbooks_by_status only accepts Status.PENDING ..."
    )

These tests will fail because they expect StorageError.

Proposed fix
 def test_delete_all_user_playbooks_by_status_archived_raises(tmp_path):
     s = _store(tmp_path)
-    with pytest.raises(StorageError, match="PENDING"):
+    with pytest.raises(ValueError, match="PENDING"):
         s.delete_all_user_playbooks_by_status(Status.ARCHIVED)


 def test_delete_all_user_playbooks_by_status_merged_raises(tmp_path):
     s = _store(tmp_path)
-    with pytest.raises(StorageError, match="PENDING"):
+    with pytest.raises(ValueError, match="PENDING"):
         s.delete_all_user_playbooks_by_status(Status.MERGED)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/server/services/storage/test_lineage_b1_harddelete_integration.py`
around lines 310 - 320, The tests
test_delete_all_user_playbooks_by_status_archived_raises and
test_delete_all_user_playbooks_by_status_merged_raises expect a StorageError to
be raised, but the delete_all_user_playbooks_by_status method implementation
currently raises ValueError when the status is not Status.PENDING. Update the
implementation to raise StorageError instead of ValueError when the status
validation check fails, ensuring the exception type matches what the tests
expect.

…lete-existence filter + vec cleanup + already-archived guard

- gate revise/status_change emit on UPDATE rowcount in update_user_playbook/update_agent_playbook/update_user_profile_by_id (completes the rowcount-guard set)
- delete_profiles_by_ids: filter to existing rows before auditing (no phantom hard_delete for nonexistent ids)
- profiles bulk hard-delete paths now clean profiles_vec sidecar rows (GDPR-correct)
- archive_agent_playbooks_by_ids/_by_playbook_name skip already-archived rows (no archived->archived event)
@yilu331

yilu331 commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough pass — addressed in 4bfac17 (on top of the earlier review-loop fixes in 5c02653). Per-comment disposition:

Fixed in 4bfac17:

  • Gate lineage emission on UPDATE rowcountupdate_user_playbook, update_agent_playbook, and update_user_profile_by_id now capture the cursor and emit revise/status_change only when cur.rowcount > 0 (this completes the guard set; update_agent_playbook_status already had it). A concurrent delete before the locked write no longer produces a phantom event.
  • delete_profiles_by_ids — filter to existing rows before auditing — now SELECTs the existing profile_ids inside _lock, emits/deletes only those, and returns 0 with no audit event when the delete is a no-op.
  • Delete *_vec sidecars in bulk hard-delete paths — profiles bulk deletes (delete_all_profiles_for_user, delete_all_profiles_by_status, delete_profiles_by_ids) now clean profiles_vec alongside FTS/base in the same locked txn (via a shared _delete_profile_search_rows helper). The playbook bulk paths were already cleaning vec in 5c02653.
  • Skip already-archived rows on archivearchive_agent_playbooks_by_ids/_by_playbook_name now add AND (status IS NULL OR status != 'archived') to both the SELECT and UPDATE, so re-archiving a non-approved already-archived row no longer emits an archived->archived event.

Already addressed in 5c02653 (the prior review-loop commit):

  • Emit hard_delete only for rows that existdelete_user_playbook/delete_agent_playbook already gate on cur.rowcount > 0.
  • request_id empty/collidingReflectionServiceRequest.request_id already uses default_factory=lambda: uuid.uuid4().hex. We kept a generated default rather than making it required: it eliminates the empty/collision case while keeping the public run_reflection(...) facade non-breaking.
  • Tests expect StorageError but impl raises ValueError — correct as-is: @handle_exceptions wraps ValueErrorStorageError, so the assertions match what propagates to callers.

False alarm:

  • _set_config(..., reflection_config={...}) will fail — verified it does not: the helper does Config.model_validate(...), and Pydantic correctly coerces a nested dict into the ReflectionConfig field (post_horizon_size is set to 0). All three reflection-supersede tests pass.

New negative tests added for each fix (nonexistent-id-no-event, partial-existence, vec-cleanup, already-archived-no-event). Suite: 214 passed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
reflexio/server/services/storage/sqlite_storage/_profiles.py (1)

333-350: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing vector sidecar cleanup in delete_all_profiles.

This method deletes from profiles_fts manually but does not clean up profiles_vec. Other bulk delete methods use _delete_profile_search_rows(pids) which handles both FTS and vec deletion. For GDPR/hard-delete compliance, embeddings should be removed alongside base data.

Suggested fix
     `@SQLiteStorageBase.handle_exceptions`
     def delete_all_profiles(self) -> None:
         batch_request_id = uuid.uuid4().hex
         with self._lock:
             pids = [
                 r["profile_id"]
                 for r in self.conn.execute("SELECT profile_id FROM profiles").fetchall()
             ]
             for pid in pids:
                 _emit_hard_delete_profile(
                     self.conn,
                     org_id=self.org_id,
                     entity_id=str(pid),
                     request_id=batch_request_id,
                 )
-            self.conn.execute("DELETE FROM profiles_fts")
+            self._delete_profile_search_rows(pids)
             self.conn.execute("DELETE FROM profiles")
             self.conn.commit()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/services/storage/sqlite_storage/_profiles.py` around lines
333 - 350, The delete_all_profiles method manually deletes from profiles_fts and
profiles but does not clean up the profiles_vec sidecar table. Replace the
manual DELETE statements for profiles_fts and profiles with a call to
_delete_profile_search_rows(pids) passing the list of profile IDs that was
already collected, which properly handles cleanup of both FTS and vector
embeddings to ensure GDPR/hard-delete compliance.
reflexio/server/services/storage/sqlite_storage/_playbook.py (1)

487-500: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Split transaction between search cleanup and base row deletion.

The search row cleanup happens inside _lock with a commit at line 497, but the base row deletion at line 499 uses _execute which acquires its own lock and commits separately. This creates a window where search rows are deleted but base rows still exist, which could cause inconsistent state if the second operation fails.

Consider moving the base row DELETE inside the same locked transaction for atomicity.

Suggested fix
         if ids:
             with self._lock:
                 self._delete_playbook_search_rows("user", ids)
-                self.conn.commit()
-
-        cur = self._execute(f"DELETE FROM user_playbooks WHERE {where}", params)
+                cur = self.conn.execute(f"DELETE FROM user_playbooks WHERE {where}", params)
+                self.conn.commit()
+        else:
+            cur = self._execute(f"DELETE FROM user_playbooks WHERE {where}", params)
         return cur.rowcount
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/services/storage/sqlite_storage/_playbook.py` around lines
487 - 500, The code currently splits the deletion operation into two separate
transactions: the search row cleanup via _delete_playbook_search_rows happens
inside the _lock block with a commit, but the base row deletion via _execute
happens afterwards outside the lock with its own transaction and commit. This
creates a window where inconsistent state can occur if the second deletion
fails. Move the _execute call for deleting from user_playbooks to inside the
same with self._lock block, before the self.conn.commit() call at line 497, so
both deletions happen atomically within a single transaction.
🧹 Nitpick comments (1)
tests/server/services/storage/test_lineage_b1_harddelete_integration.py (1)

375-380: ⚡ Quick win

Scope profile hard-delete assertions to entity_type="profile".

These assertions currently filter only by entity_id; adding entity_type avoids false positives/negatives if another entity type reuses the same id in future test setup.

Proposed patch
-    assert not any(
-        e.op == "hard_delete" for e in s.get_lineage_events(entity_id="ghost-1")
-    )
+    assert not any(
+        e.op == "hard_delete"
+        for e in s.get_lineage_events(entity_id="ghost-1", entity_type="profile")
+    )
-    assert not any(
-        e.op == "hard_delete" for e in s.get_lineage_events(entity_id="ghost-2")
-    )
+    assert not any(
+        e.op == "hard_delete"
+        for e in s.get_lineage_events(entity_id="ghost-2", entity_type="profile")
+    )

     real_events = [
-        e for e in s.get_lineage_events(entity_id="real-1") if e.op == "hard_delete"
+        e
+        for e in s.get_lineage_events(entity_id="real-1", entity_type="profile")
+        if e.op == "hard_delete"
     ]
     assert len(real_events) == 1
     assert not any(
-        e.op == "hard_delete" for e in s.get_lineage_events(entity_id="ghost-3")
+        e.op == "hard_delete"
+        for e in s.get_lineage_events(entity_id="ghost-3", entity_type="profile")
     )

Also applies to: 389-395

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/server/services/storage/test_lineage_b1_harddelete_integration.py`
around lines 375 - 380, The assertions checking for hard_delete operations in
the lineage events are currently filtering only by entity_id parameter in the
get_lineage_events() calls. To make these assertions more specific and prevent
false positives/negatives if another entity type reuses the same id in future
test changes, add entity_type="profile" parameter to both get_lineage_events()
method calls in the affected assertions (the ones checking entity_id="ghost-1"
and entity_id="ghost-2" at lines 375-380, and also the similar assertions
mentioned at lines 389-395).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@reflexio/server/services/storage/sqlite_storage/_playbook.py`:
- Around line 487-500: The code currently splits the deletion operation into two
separate transactions: the search row cleanup via _delete_playbook_search_rows
happens inside the _lock block with a commit, but the base row deletion via
_execute happens afterwards outside the lock with its own transaction and
commit. This creates a window where inconsistent state can occur if the second
deletion fails. Move the _execute call for deleting from user_playbooks to
inside the same with self._lock block, before the self.conn.commit() call at
line 497, so both deletions happen atomically within a single transaction.

In `@reflexio/server/services/storage/sqlite_storage/_profiles.py`:
- Around line 333-350: The delete_all_profiles method manually deletes from
profiles_fts and profiles but does not clean up the profiles_vec sidecar table.
Replace the manual DELETE statements for profiles_fts and profiles with a call
to _delete_profile_search_rows(pids) passing the list of profile IDs that was
already collected, which properly handles cleanup of both FTS and vector
embeddings to ensure GDPR/hard-delete compliance.

---

Nitpick comments:
In `@tests/server/services/storage/test_lineage_b1_harddelete_integration.py`:
- Around line 375-380: The assertions checking for hard_delete operations in the
lineage events are currently filtering only by entity_id parameter in the
get_lineage_events() calls. To make these assertions more specific and prevent
false positives/negatives if another entity type reuses the same id in future
test changes, add entity_type="profile" parameter to both get_lineage_events()
method calls in the affected assertions (the ones checking entity_id="ghost-1"
and entity_id="ghost-2" at lines 375-380, and also the similar assertions
mentioned at lines 389-395).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6a555597-f65c-415b-bd73-747cb8890b0d

📥 Commits

Reviewing files that changed from the base of the PR and between 5c02653 and 4bfac17.

📒 Files selected for processing (4)
  • reflexio/server/services/storage/sqlite_storage/_playbook.py
  • reflexio/server/services/storage/sqlite_storage/_profiles.py
  • tests/server/services/storage/test_lineage_b1_harddelete_integration.py
  • tests/server/services/storage/test_lineage_b1_update_integration.py

yilu331 added 4 commits June 20, 2026 19:00
…only after the mutation, guarded on rowcount

The shared sqlite3 connection is one transaction; _fts_*/_vec_* helpers self-commit, so an
'emit event -> fts/vec cleanup -> mutate -> commit' ordering flushes the audit event before
the mutation. Emit after (or in the same commit as) the mutation, guarded on cur.rowcount>0;
existence check must use the same predicate (incl. user_id) as the mutation.
…te_* status_change path

- update_agent_playbook: when playbook_status is among the updated fields, read
  prior playbook_status inside the existing lock block (before the UPDATE) and
  pass from_status=<prior>, to_status=<new>, status_namespace='playbook_status'
  to _append_event_stmt. Metadata-only updates (no playbook_status, no content)
  leave all three fields NULL. Moves the not-found guard inside the lock so the
  SELECT and UPDATE are atomic.

- update_user_playbook: adds a status: Status | None = None parameter (the
  lifecycle status column). When status is set, reads prior status inside the
  lock and passes from_status=<prior>, to_status=<new>,
  status_namespace='lifecycle_status'. Metadata-only updates leave all three
  fields NULL. Same atomic not-found guard as above.

- Both methods: op='revise' (content set) branch leaves all structured fields
  NULL, unchanged from prior behaviour.

Tests added to test_lineage_b1_update_integration.py:
  - update_agent_playbook(playbook_status=X): to/from/ns populated (pending->approved)
  - update_agent_playbook(playbook_name=...): all 3 fields NULL
  - update_user_playbook(status=ARCHIVED) with NULL prior: from_status=None, to=archived, ns=lifecycle_status
  - update_user_playbook(status=ARCHIVED) with PENDING prior: from_status=pending
  - update_user_playbook(playbook_name=...): all 3 fields NULL
… (keep structured fields NULL where no status flips)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
reflexio/server/services/storage/sqlite_storage/_playbook.py (3)

492-505: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Emit hard_delete and delete the audited IDs atomically.

delete_all_user_playbooks_by_status() deletes base rows without any hard_delete event, and it commits search-row cleanup before the physical delete. That leaves this hard-delete path outside the lineage contract and can desynchronize search/base rows on failure. Select IDs under the lock, emit per-ID lineage, clean search rows, and delete those same IDs in one transaction.

Proposed fix
-        # Clean up FTS + vec
-        ids = [
-            r["user_playbook_id"]
-            for r in self._fetchall(
-                f"SELECT user_playbook_id FROM user_playbooks WHERE {where}", params
-            )
-        ]
-        if ids:
-            with self._lock:
-                self._delete_playbook_search_rows("user", ids)
-                self.conn.commit()
-
-        cur = self._execute(f"DELETE FROM user_playbooks WHERE {where}", params)
-        return cur.rowcount
+        batch_request_id = uuid.uuid4().hex
+        with self._lock:
+            ids = [
+                r["user_playbook_id"]
+                for r in self.conn.execute(
+                    f"SELECT user_playbook_id FROM user_playbooks WHERE {where}",
+                    params,
+                ).fetchall()
+            ]
+            if not ids:
+                self.conn.commit()
+                return 0
+
+            for upid in ids:
+                _emit_hard_delete_playbook(
+                    self.conn,
+                    org_id=self.org_id,
+                    entity_type="user_playbook",
+                    entity_id=str(upid),
+                    request_id=batch_request_id,
+                )
+
+            self._delete_playbook_search_rows("user", ids)
+            ph = ",".join("?" for _ in ids)
+            cur = self.conn.execute(
+                f"DELETE FROM user_playbooks WHERE user_playbook_id IN ({ph})",
+                ids,
+            )
+            self.conn.commit()
+            return cur.rowcount
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/services/storage/sqlite_storage/_playbook.py` around lines
492 - 505, The delete_all_user_playbooks_by_status method performs search row
cleanup and commits before deleting base rows, breaking atomicity and missing
hard_delete lineage events. Under the _lock, select the IDs and emit hard_delete
events for each ID to maintain lineage tracking, then call
_delete_playbook_search_rows to clean up search rows, execute the DELETE FROM
user_playbooks query with those same IDs, and finally commit once at the end so
all operations (ID selection, lineage emission, search cleanup, and physical
delete) happen atomically.

1014-1055: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve status lineage when content and status change together.

When content and playbook_status are both provided, the event is classified as revise, so the structured status fields stay NULL and no status_change event records the playbook_status transition. Emit an additional status_change event when the status actually changes; the idempotency key already includes op, so it can share the same request ID.

Suggested shape
                 if cur.rowcount > 0:
+                    event_request_id = uuid.uuid4().hex
                     # Populate structured status fields only when playbook_status is
                     # among the updated fields and the op is status_change (not revise).
                     if op == "status_change" and playbook_status is not None:
                         from_status = prior_playbook_status
                         to_status = playbook_status.value
@@
                         actor="api",
-                        request_id=uuid.uuid4().hex,
+                        request_id=event_request_id,
                         reason="in-place update",
                         from_status=from_status,
                         to_status=to_status,
                         status_namespace=status_namespace,
                     )
+                    if (
+                        op == "revise"
+                        and playbook_status is not None
+                        and prior_playbook_status != playbook_status.value
+                    ):
+                        _append_event_stmt(
+                            self.conn,
+                            org_id=self.org_id,
+                            entity_type="agent_playbook",
+                            entity_id=str(agent_playbook_id),
+                            op="status_change",
+                            prov="wasInvalidatedBy",
+                            source_ids=[],
+                            actor="api",
+                            request_id=event_request_id,
+                            reason=f"{prior_playbook_status or 'None'}->{playbook_status.value}",
+                            from_status=prior_playbook_status,
+                            to_status=playbook_status.value,
+                            status_namespace="playbook_status",
+                        )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/services/storage/sqlite_storage/_playbook.py` around lines
1014 - 1055, The issue is that when both content and playbook_status are updated
together, the operation is classified as "revise" rather than "status_change",
causing the status transition information to not be recorded. After the existing
_append_event_stmt call for the "revise" operation, add an additional
_append_event_stmt call to emit a "status_change" event whenever the
playbook_status actually changes and is not None. This second event should use
op="status_change", prov="wasInvalidatedBy", set from_status to
prior_playbook_status, set to_status to playbook_status.value, set
status_namespace to "playbook_status", and reuse the same request_id from the
first event since the op field is part of the idempotency key.

333-347: ⚠️ Potential issue | 🟠 Major

Switch single-row deletes to use transaction-scoped cleanup helper to prevent orphaned sidecars.

_fts_delete and _vec_delete both call self.conn.commit() internally. At lines 333–347 and 861–875, these are called before the base table delete and lineage emission; if either of those later operations fail, the FTS and vector sidecars remain committed while the main delete is rolled back, leaving orphaned search rows.

Switch to _delete_playbook_search_rows("user", [user_playbook_id]) and _delete_playbook_search_rows("agent", [agent_playbook_id]) to keep all sidecar cleanup within the shared transaction scope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/services/storage/sqlite_storage/_playbook.py` around lines
333 - 347, The issue is that _fts_delete and _vec_delete methods are called
individually at the beginning of the deletion block and each internally commits
their changes, which can leave orphaned FTS and vector sidecar rows if the
subsequent base table delete or lineage emission fails. Replace the two separate
calls to _fts_delete("user_playbooks_fts", user_playbook_id) and
_vec_delete("user_playbooks_vec", user_playbook_id) with a single call to
_delete_playbook_search_rows("user", [user_playbook_id]) to ensure all cleanup
operations remain within the same transaction scope as the main DELETE operation
on user_playbooks and the _emit_hard_delete_playbook call.
♻️ Duplicate comments (1)
reflexio/server/services/storage/sqlite_storage/_playbook.py (1)

389-405: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Filter to existing IDs before emitting hard_delete.

These by-id bulk deletes still append hard_delete for every caller-supplied ID, but the final DELETE ... IN (...) may affect fewer rows. A stale ID therefore records a deletion that never happened. Snapshot existing IDs inside the lock and use that same set for event emission, search cleanup, and base deletion.

Suggested shape
         batch_request_id = uuid.uuid4().hex
         with self._lock:
+            existing_ids = [
+                r["user_playbook_id"]
+                for r in self.conn.execute(
+                    f"SELECT user_playbook_id FROM user_playbooks WHERE user_playbook_id IN ({ph})",
+                    user_playbook_ids,
+                ).fetchall()
+            ]
+            if not existing_ids:
+                self.conn.commit()
+                return 0
+            ph = ",".join("?" for _ in existing_ids)
             if emit_hard_delete:
-                for upid in user_playbook_ids:
+                for upid in existing_ids:
                     _emit_hard_delete_playbook(
                         self.conn,
                         org_id=self.org_id,
                         entity_type="user_playbook",
                         entity_id=str(upid),
                         request_id=batch_request_id,
                         actor="system",
                     )
-            self._delete_playbook_search_rows("user", user_playbook_ids)
+            self._delete_playbook_search_rows("user", existing_ids)
             cur = self.conn.execute(
                 f"DELETE FROM user_playbooks WHERE user_playbook_id IN ({ph})",
-                user_playbook_ids,
+                existing_ids,
             )

Apply the same existing-ID snapshot pattern to delete_agent_playbooks_by_ids.

Also applies to: 918-933

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/services/storage/sqlite_storage/_playbook.py` around lines
389 - 405, In the delete logic after the emit_hard_delete check, the code is
emitting hard_delete events and performing deletes on all caller-supplied
user_playbook_ids without first verifying they exist in the database. Before the
hard_delete loop, query the database to retrieve only the user_playbook_ids that
actually exist, then use this filtered set for both the hard_delete emission
loop and the subsequent DELETE operation. Apply the same existing-ID snapshot
pattern to the delete_agent_playbooks_by_ids method as well.
🧹 Nitpick comments (2)
reflexio/server/services/storage/sqlite_storage/_base.py (1)

1249-1262: ⚡ Quick win

Keep lineage_event CREATE definitions aligned to reduce migration drift.

Line 1249–1262 omits from_status, to_status, and status_namespace even though _DDL includes them. It works because of the later ALTER loop, but duplicating two different CREATE definitions increases maintenance risk.

Suggested diff
                 CREATE TABLE IF NOT EXISTS lineage_event (
                     event_id         INTEGER PRIMARY KEY AUTOINCREMENT,
                     org_id           TEXT NOT NULL,
                     entity_type      TEXT NOT NULL,
                     entity_id        TEXT NOT NULL,
                     op               TEXT NOT NULL,
                     prov_relation    TEXT NOT NULL DEFAULT '',
                     source_ids       TEXT NOT NULL DEFAULT '[]',
                     actor            TEXT NOT NULL DEFAULT '',
                     request_id       TEXT NOT NULL DEFAULT '',
                     reason           TEXT NOT NULL DEFAULT '',
                     created_at       INTEGER NOT NULL,
+                    from_status      TEXT,
+                    to_status        TEXT,
+                    status_namespace TEXT,
                     UNIQUE (org_id, entity_type, entity_id, op, request_id)
                 );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/services/storage/sqlite_storage/_base.py` around lines 1249 -
1262, The CREATE TABLE IF NOT EXISTS lineage_event statement (lines 1249-1262)
is missing three columns that exist in the _DDL definition: from_status,
to_status, and status_namespace. Add these three missing columns to the
lineage_event table creation to align with the _DDL definition and eliminate the
maintenance risk of having duplicate and divergent table definitions. This will
ensure the schema is defined completely in one place rather than relying on a
later ALTER loop to add the missing columns.
tests/server/services/storage/test_lineage_b1_statuschange_integration.py (1)

465-467: ⚡ Quick win

Filter lineage queries by entity_type to avoid future ID-collision flakes.

These lookups currently filter only by entity_id (e.g., Line 465). Since IDs can overlap across entity tables, include entity_type in get_lineage_events(...) for deterministic scoping.

Example adjustment
-    evts = [
-        e for e in s.get_lineage_events(entity_id=str(apid)) if e.op == "status_change"
-    ]
+    evts = [
+        e
+        for e in s.get_lineage_events(
+            entity_type="agent_playbook",
+            entity_id=str(apid),
+        )
+        if e.op == "status_change"
+    ]

Also applies to: 486-488, 502-504, 523-526, 540-543, 557-559, 572-574, 589-591

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/server/services/storage/test_lineage_b1_statuschange_integration.py`
around lines 465 - 467, The get_lineage_events() calls are filtering only by
entity_id parameter, which can cause ID-collision issues since IDs can overlap
across different entity tables. Update all instances where get_lineage_events()
is called with only entity_id (at lines 465-467, 486-488, 502-504, 523-526,
540-543, 557-559, 572-574, and 589-591) to include the entity_type parameter as
well. This ensures deterministic scoping by specifying both the entity_id and
its corresponding entity_type, preventing future test flakiness from ID
collisions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/server/services/storage/test_lineage_b1_update_integration.py`:
- Around line 223-224: The get_lineage_events call in the test is filtering only
by entity_id without specifying entity_type, which can cause test flakiness if
other entity types share the same ID. Modify the get_lineage_events method call
to include the entity_type parameter set to "user_playbook" alongside the
existing entity_id parameter to ensure deterministic test behavior and prevent
cross-entity ID collisions.

---

Outside diff comments:
In `@reflexio/server/services/storage/sqlite_storage/_playbook.py`:
- Around line 492-505: The delete_all_user_playbooks_by_status method performs
search row cleanup and commits before deleting base rows, breaking atomicity and
missing hard_delete lineage events. Under the _lock, select the IDs and emit
hard_delete events for each ID to maintain lineage tracking, then call
_delete_playbook_search_rows to clean up search rows, execute the DELETE FROM
user_playbooks query with those same IDs, and finally commit once at the end so
all operations (ID selection, lineage emission, search cleanup, and physical
delete) happen atomically.
- Around line 1014-1055: The issue is that when both content and playbook_status
are updated together, the operation is classified as "revise" rather than
"status_change", causing the status transition information to not be recorded.
After the existing _append_event_stmt call for the "revise" operation, add an
additional _append_event_stmt call to emit a "status_change" event whenever the
playbook_status actually changes and is not None. This second event should use
op="status_change", prov="wasInvalidatedBy", set from_status to
prior_playbook_status, set to_status to playbook_status.value, set
status_namespace to "playbook_status", and reuse the same request_id from the
first event since the op field is part of the idempotency key.
- Around line 333-347: The issue is that _fts_delete and _vec_delete methods are
called individually at the beginning of the deletion block and each internally
commits their changes, which can leave orphaned FTS and vector sidecar rows if
the subsequent base table delete or lineage emission fails. Replace the two
separate calls to _fts_delete("user_playbooks_fts", user_playbook_id) and
_vec_delete("user_playbooks_vec", user_playbook_id) with a single call to
_delete_playbook_search_rows("user", [user_playbook_id]) to ensure all cleanup
operations remain within the same transaction scope as the main DELETE operation
on user_playbooks and the _emit_hard_delete_playbook call.

---

Duplicate comments:
In `@reflexio/server/services/storage/sqlite_storage/_playbook.py`:
- Around line 389-405: In the delete logic after the emit_hard_delete check, the
code is emitting hard_delete events and performing deletes on all
caller-supplied user_playbook_ids without first verifying they exist in the
database. Before the hard_delete loop, query the database to retrieve only the
user_playbook_ids that actually exist, then use this filtered set for both the
hard_delete emission loop and the subsequent DELETE operation. Apply the same
existing-ID snapshot pattern to the delete_agent_playbooks_by_ids method as
well.

---

Nitpick comments:
In `@reflexio/server/services/storage/sqlite_storage/_base.py`:
- Around line 1249-1262: The CREATE TABLE IF NOT EXISTS lineage_event statement
(lines 1249-1262) is missing three columns that exist in the _DDL definition:
from_status, to_status, and status_namespace. Add these three missing columns to
the lineage_event table creation to align with the _DDL definition and eliminate
the maintenance risk of having duplicate and divergent table definitions. This
will ensure the schema is defined completely in one place rather than relying on
a later ALTER loop to add the missing columns.

In `@tests/server/services/storage/test_lineage_b1_statuschange_integration.py`:
- Around line 465-467: The get_lineage_events() calls are filtering only by
entity_id parameter, which can cause ID-collision issues since IDs can overlap
across different entity tables. Update all instances where get_lineage_events()
is called with only entity_id (at lines 465-467, 486-488, 502-504, 523-526,
540-543, 557-559, 572-574, and 589-591) to include the entity_type parameter as
well. This ensures deterministic scoping by specifying both the entity_id and
its corresponding entity_type, preventing future test flakiness from ID
collisions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5de3cd0c-55d4-428c-898e-5160e5e02b81

📥 Commits

Reviewing files that changed from the base of the PR and between 5ad1bd4 and 0cd21c6.

📒 Files selected for processing (8)
  • reflexio/models/api_schema/domain/entities.py
  • reflexio/server/services/storage/sqlite_storage/_base.py
  • reflexio/server/services/storage/sqlite_storage/_lineage.py
  • reflexio/server/services/storage/sqlite_storage/_playbook.py
  • reflexio/server/services/storage/sqlite_storage/_profiles.py
  • tests/server/services/storage/test_lineage_b1_statuschange_integration.py
  • tests/server/services/storage/test_lineage_b1_update_integration.py
  • tests/server/services/storage/test_lineage_contract.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • reflexio/server/services/storage/sqlite_storage/_profiles.py

Comment on lines +223 to +224
ev = s.get_lineage_events(entity_id=str(pb.user_playbook_id))
sc = [e for e in ev if e.op == "status_change"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add entity_type filter to avoid cross-entity ID collisions in lineage lookup.

On Line 223, this test filters only by entity_id, unlike the adjacent agent-playbook tests. If another entity type reuses the same ID, the status_change selection on Line 224 can become flaky. Use entity_type="user_playbook" here for deterministic assertions.

Suggested patch
-    ev = s.get_lineage_events(entity_id=str(pb.user_playbook_id))
+    ev = s.get_lineage_events(
+        entity_id=str(pb.user_playbook_id),
+        entity_type="user_playbook",
+    )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ev = s.get_lineage_events(entity_id=str(pb.user_playbook_id))
sc = [e for e in ev if e.op == "status_change"]
ev = s.get_lineage_events(
entity_id=str(pb.user_playbook_id),
entity_type="user_playbook",
)
sc = [e for e in ev if e.op == "status_change"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/server/services/storage/test_lineage_b1_update_integration.py` around
lines 223 - 224, The get_lineage_events call in the test is filtering only by
entity_id without specifying entity_type, which can cause test flakiness if
other entity types share the same ID. Modify the get_lineage_events method call
to include the entity_type parameter set to "user_playbook" alongside the
existing entity_id parameter to ensure deterministic test behavior and prevent
cross-entity ID collisions.

@yilu331

yilu331 commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator Author

CodeRabbit findings addressed in follow-up PR #193 (commits a5f6558 + f792ddb): bulk-delete phantom-audit guards — emit hard_delete only for existing rows, in the same commit as the base DELETE, with FTS/vec cleanup moved after the commit (per the SQLite self-commit rule); this fixed delete_all_agent_playbooks and delete_archived_agent_playbooks_by_playbook_name (both emitted before the mutation). Also added the entity_type filter to the lineage-lookup test.
Confirmed false alarm (not changed): the _set_config(**kwargs) 🔴 CriticalConfig.model_validate({..., **overrides}) is valid Pydantic; the test passes and applies the right config.
Already-fixed by later phases (verified, skipped): rowcount gates, already-archived exclusion, update_user_profile_by_id atomicity, delete_profiles_by_ids existing-row filter, supersede_profiles_by_ids atomicity, vec-sidecar cleanup, request_id non-optional, StorageError wrapping.

yilu331 added a commit that referenced this pull request Jun 21, 2026
/#191) (#193)

## What

Addresses the CodeRabbit review findings left on the **merged** lineage
PRs **#187 (B1)**, **#188 (B2)**, and **#191 (B3-pre)**. Pure
remediation — no new features.

## Fixes by source PR

**#191 (B3-pre)** — `22d2540`: negative-`limit` guard (`<=0`) in
reconstruction; `enabled_org_ids or []` defensiveness in feature flags;
parity script **fail-closed on duplicate `request_id`** + **INCONCLUSIVE
(exit 2) on at-cap/truncated reads**; test hygiene (vacuous-MATCH guard,
RECON-vs-LEGACY label, exact request_id set-equality, empty-id seeding);
ASCII `union` (RUF002).

**#188 (B2)** — `4204598`: `LineageGCConfig` bounds (`Field(gt=0)`);
scheduler poll-interval clamp; `gc_expired_tombstones` `limit<=0` guard;
**explicit rollback** to keep GC atomic on mid-write failure.

**#187 (B1)** — `a5f6558` + `f792ddb`: **phantom-audit guards on the
bulk-delete paths** (emit `hard_delete` only for rows that exist, in the
**same commit** as the base DELETE; FTS/vec cleanup moved **after** the
commit per the SQLite self-commit rule) — fixing
`delete_all_agent_playbooks` and
`delete_archived_agent_playbooks_by_playbook_name`, which emitted
*before* the mutation; `entity_type` filter in a lineage-lookup test.

## Deliberately NOT changed (with reasons)

- **SQLite `get_profiles_by_generated_from_request_id` "not
org-scoped"** — SQLite `profiles` has **no `org_id` column** (tenant
isolation is per-DB-file; enterprise is per-schema), and the
reconstruction's event pool is already `org_id`-scoped via
`get_lineage_events`. Adding a profiles-level org filter needs a schema
migration — tracked as a separate follow-up, not a quick fix.
- **`_set_config(**kwargs)` `🔴 Critical`** — **confirmed false alarm**:
`Config.model_validate({..., **overrides})` is valid Pydantic; the test
passes and applies the right config.
- Several #187 items were **already addressed** by later phases
(rowcount gates, already-archived exclusion, atomicity, existing-row
filters, vec-sidecar cleanup, `request_id` non-optional, `StorageError`
wrapping) — verified against current code and skipped.

All changes tested (reviewed via a final whole-branch pass that caught
the bulk-delete ordering). No behavior change beyond the hardening
above.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added validation for scheduler configuration values to ensure they
meet minimum requirements.

* **Bug Fixes**
  * Improved transaction atomicity during garbage collection operations.
* Fixed feature flag evaluation to safely handle missing or null org ID
lists.
  * Enhanced duplicate detection in data parity validation.
  * Enforced minimum scheduler poll interval to improve reliability.

* **Tests**
* Added comprehensive tests for configuration validation and garbage
collection scenarios.
  * Strengthened parity checking and deletion operation test coverage.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
yilu331 added a commit that referenced this pull request Jun 21, 2026
…NG (unblock playbook upgrade on SQLite) (#194)

## Summary

`/test-backend-pipeline` run found a **P1 regression** in the playbook
upgrade flow on the SQLite backend.

Lineage Phase B1 (#187) added a `status != Status.PENDING` → `raise
ValueError` guard to the SQLite `delete_all_user_playbooks_by_status`.
But the shared upgrade flow (`base_generation_service._perform_upgrade`)
calls `_delete_items_by_status(Status.ARCHIVED, request)` to purge old
archived playbooks. As a result, `upgrade_all_user_playbooks` broke on
SQLite — it returned `UpgradeUserPlaybooksResponse(success=False, ...)`
with the guard error swallowed by `handle_exceptions`.

The **Supabase backend has no such guard**: it routes the same delete
through `_hard_delete_and_log(..., emit_hard_delete=False)` and accepts
any status. So this was a SQLite-only regression and a cross-backend
divergence that the storage contract suite does not cover
(`delete_all_user_playbooks_by_status` has no contract test).

## Root cause

| Backend | `delete_all_user_playbooks_by_status(ARCHIVED)` | Lineage |
|---|---|---|
| SQLite (pre-fix) | **raises ValueError** (B1 guard) | n/a |
| SQLite (post-fix) | deletes rows | none (matches Supabase) |
| Supabase | deletes rows | none (`emit_hard_delete=False`) |
| `delete_all_profiles_by_status` (both backends) | deletes rows | emits
`hard_delete` |

## Fix

Remove the PENDING-only guard so SQLite matches the Supabase sibling:
bulk delete-by-status accepts any status and (by design, matching
Supabase) emits no `hard_delete` lineage events. The `PENDING` purge
already emitted no events, so behavior for the original carve-out is
unchanged.

Updated the two B1 integration tests that asserted the raise
(`..._archived_raises`, `..._merged_raises`) into one parametrized test
asserting the corrected behavior: the row is physically deleted and
**no** `hard_delete` event is emitted.

## Verification

-
`tests/server/services/storage/test_lineage_b1_harddelete_integration.py`
— **34 passed**
-
`tests/e2e_tests/test_playbook_workflows.py::test_upgrade_user_playbooks_end_to_end`
— **passed** (was failing)
- `tests/e2e_tests/test_playbook_workflows.py` (full file) — **8 passed,
16 skipped** (was 1 failed)
- `ruff check` / `ruff format --check` / `pyright` — clean

## Follow-up (not in this PR)

The storage contract suite does not exercise
`delete_all_user_playbooks_by_status`, which is why SQLite and Supabase
diverged silently. A contract test covering this method (PENDING + a
non-PENDING status, asserting deletion and the no-lineage carve-out)
would prevent recurrence.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant