Skip to content

fix(storage): quarantine real cycles in the live session_links resolver - #3643

Merged
Sinity merged 1 commit into
masterfrom
feature/storage/quarantine-session-link-cycles
Aug 3, 2026
Merged

fix(storage): quarantine real cycles in the live session_links resolver#3643
Sinity merged 1 commit into
masterfrom
feature/storage/quarantine-session-link-cycles

Conversation

@Sinity

@Sinity Sinity commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Summary

Ports cycle detection + quarantine into the sole production writer of session_links rows, so a genuine cycle in the parent chain is now detected and recorded with evidence instead of silently relying on recursion-guard short-circuits.

Problem

session_links.status/.method are declared vocabulary (TopologyEdgeStatus), but a live-archive measurement (2026-07-28, index v43, 18,871 sessions) found every one of 9,179 topology edges had empty status/method. method is already populated as of #3390 (2026-07-30, landed before this bead's structural audit); status stayed NULL on every row because nothing in the live write path ever detects or records a cycle.

A 2026-08-03 structural audit (polylogue-structural-audit-2026-08-03.html) found the real gap is worse than an unwritten column: a full cycle-detection + quarantine engine already exists at storage/sqlite/queries/session_links.py (_would_create_cycle, _quarantine_link, status='quarantined' + evidence JSON) but has zero production callers — it is exercised only by tests/unit/insights/test_topology_cycle_rejection.py, a test-only surface certifying behavior production code cannot exhibit. The live writer (write_parsed_session_to_archive -> _resolve_session_graph / _resolve_outbound_session_links, storage/sqlite/archive_tiers/write.py) resolves every matching parent edge unconditionally; a real cycle is only ever avoided by _refresh_session_projection's seen-set short-circuit and _composed_db_signatures' visited-set truncation, which silently pick an arbitrary root/branch point on a real cycle instead of persisting evidence of the rejected edge, making root/branch projections order-dependent and arbitrary.

Given polylogue-5dfu already narrowed TopologyEdgeStatus to just REPAIRED/QUARANTINED (NULL = ordinary resolved/unresolved, not "missing data"), the correct fix is not "every row must carry a non-NULL status" — it's "when a cycle actually occurs, the closing edge must be quarantined with evidence, and this must happen in the live path, not only in dead test-only code."

Solution

  • Ported _would_create_cycle (walks sessions.parent_session_id upward from a proposed parent, bounded by a 1024-step budget, matching the dead engine's algorithm) and _quarantine_link (_quarantine_session_link here) as synchronous sqlite3-based helpers in storage/sqlite/archive_tiers/write.py, next to the functions they protect.
  • Wired cycle checking into both live resolution entry points:
    • _resolve_outbound_session_links (a session resolving its own parent reference) now evaluates each unresolved candidate individually instead of one blanket UPDATE, cycle-checking before resolving.
    • The inbound-child loop inside _resolve_session_graph (a session resolving pending children that reference it as their parent) does the same per child.
  • A candidate whose resolution would close a loop is quarantined (status='quarantined', evidence_json recording {reason, cycle_path, detected_at_ms}) instead of resolved; its parent_session_id projection stays NULL, so composition/ancestry walks degrade visibly (the session surfaces as its own root) rather than silently entering the cycle.
  • Both entry points now also filter on status IS NULL (in addition to resolved_dst_session_id IS NULL), so a quarantined edge is never reconsidered on a later write — idempotent by construction.

Alternatives rejected

  • Retargeting tests/unit/insights/test_topology_cycle_rejection.py (621 lines) and the session_links-dependent slice of tests/unit/storage/test_delegations_view.py off the dead async engine, and deleting storage/sqlite/queries/session_links.py, is explicitly not done in this PR. That's a large, separable retarget-and-delete step (both files currently pass, unchanged, because this PR only adds behavior to the live path and does not touch the dead module) and is called out as follow-up scope below rather than rushed into this PR.

Verification

  • New tests: tests/unit/storage/test_topology_cycle_quarantine_live.py — reproduces a genuine cross-ingest two-node cycle (A lands root, B lands as A's child and resolves immediately, A is then re-ingested claiming B as its parent), a self-referential edge, and a non-cyclic diamond-DAG control case (B and C both point at D — a legitimate shared parent, must resolve cleanly, not be mistaken for a cycle), all through the real write_parsed_session_to_archive path.
  • Confirmed red without the write.py change: 2 of 3 new tests failed with assert None == 'quarantined' (stashed the fix out, reran, restored it).
  • devtools test tests/unit/storage/test_topology_cycle_quarantine_live.py tests/unit/storage/test_lineage_normalization.py tests/unit/storage/test_session_topology.py tests/unit/storage/test_topology_edges.py tests/unit/storage/test_archive_tiers_write.py tests/unit/storage/test_bulk_fts_prefix_reextract.py — 142 passed.
  • ruff check / ruff format --check / mypy --strict clean on both touched files.
  • devtools verify --quick — exit 0.

Acceptance criteria (polylogue-4ts.10)

  1. "Every session_links row written by resolve_session_links_for_session carries a TopologyEdgeStatus value and a method token" — misframed by polylogue-5dfu's prior narrowing: method is satisfied (populated since feat(archive): index v46 wire-evidence batch, free-threaded-only runtime, parse-failure recovery #3390); status legitimately stays NULL for ordinary resolved/unresolved edges (that's the narrowed vocabulary's intent) and is now populated (quarantined) exactly when a cycle is detected, which is the actual gap this bead's own structural-audit note identified.
  2. "Existing rows acquire status through ordinary derived-tier rebuild, not a bespoke backfill script" — satisfied: this is a derived-tier (index.db) behavior change with no migration; a polylogue ops reset --index && polylogued run naturally re-derives status/method for every row through the now-fixed live path.
  3. "A reader can filter edges by status, and composition refuses (or degrades visibly) on a non-resolved parent rather than silently composing" — satisfied for composition (a quarantined child's parent_session_id stays NULL, so it surfaces as its own root); a dedicated reader/insight surface for quarantined-edge counts is deferred (the dead engine's count_quarantined_session_links is one candidate to port, left for the follow-up retarget work below).
  4. "Live re-measure shows zero empty status/method rows and a status distribution consistent with the 222 unresolved-destination rows" — not independently re-measured against the live archive in this PR (no access to /realm/db/polylogue from this worktree); the mechanism this AC depends on is now live, so a follow-up index rebuild + re-measure is the natural next step, not additional code here.

Remaining polylogue-4ts.10 scope (follow-up)

Ref polylogue-4ts.10

Problem: session_links.status/.method were declared vocabulary
(TopologyEdgeStatus) with no live writer -- the sole production writer
(write_parsed_session_to_archive -> _resolve_session_graph /
_resolve_outbound_session_links) resolved every matching parent edge
unconditionally, relying only on _refresh_session_projection's seen-set
short-circuit to avoid infinite recursion on a genuine cycle. A real
cycle-detection + quarantine engine existed only in
storage/sqlite/queries/session_links.py, which has zero production
callers (test-only, exercised solely by
tests/unit/insights/test_topology_cycle_rejection.py). method is already
populated as of #3390 (2026-07-30); status stayed NULL on every row
because nothing detected or recorded a cycle in the live path.

Solution: port the dead engine's cycle-walk + quarantine algorithm
(_would_create_cycle / _quarantine_link) into
storage/sqlite/archive_tiers/write.py as sync helpers
(_would_create_cycle / _quarantine_session_link), and wire them into both
live resolution entry points: _resolve_outbound_session_links (a session
resolving its own parent reference) and the inbound-child loop inside
_resolve_session_graph (a session resolving pending children that
reference it). A candidate edge whose resolution would close a loop in
sessions.parent_session_id is now quarantined with a JSON evidence
document (reason/cycle_path/detected_at_ms) instead of resolved, and its
parent_session_id projection stays NULL -- composition degrades visibly
(the session surfaces as its own root) rather than silently entering the
cycle. Both entry points now filter on `status IS NULL` in addition to
`resolved_dst_session_id IS NULL` so a quarantined edge is never
reconsidered on a later write (idempotent).

Added tests/unit/storage/test_topology_cycle_quarantine_live.py,
reproducing a genuine cross-ingest two-node cycle, a self-referential
edge, and a non-cyclic diamond-DAG control case through the real
write_parsed_session_to_archive path (not the dead async engine).
Verified red without the write.py change (2 of 3 new tests failed with
`assert None == 'quarantined'`), green with it.

Not in this commit's scope (left for follow-up, see PR body): retargeting
tests/unit/insights/test_topology_cycle_rejection.py and the
session_links-dependent slice of tests/unit/storage/test_delegations_view.py
off the dead async engine, and deleting
storage/sqlite/queries/session_links.py. Both still pass unchanged since
this change only adds behavior to the live path and does not touch the
dead module.

Verification: devtools test tests/unit/storage/test_topology_cycle_quarantine_live.py
tests/unit/storage/test_lineage_normalization.py tests/unit/storage/test_session_topology.py
tests/unit/storage/test_topology_edges.py tests/unit/storage/test_archive_tiers_write.py
tests/unit/storage/test_bulk_fts_prefix_reextract.py -- 142 passed.
ruff check/format --check and mypy --strict clean on both touched files.

Ref polylogue-4ts.10
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@Sinity, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 25 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 88453b4a-8dfd-4588-8c23-b0b4d1a26c29

📥 Commits

Reviewing files that changed from the base of the PR and between 5cea156 and 9289798.

📒 Files selected for processing (2)
  • polylogue/storage/sqlite/archive_tiers/write.py
  • tests/unit/storage/test_topology_cycle_quarantine_live.py

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.

@Sinity
Sinity merged commit 471b2d1 into master Aug 3, 2026
2 of 3 checks passed
@Sinity
Sinity deleted the feature/storage/quarantine-session-link-cycles branch August 3, 2026 11:53
Sinity added a commit that referenced this pull request Aug 3, 2026
…3655)

## Summary
Completes the remaining polylogue-4ts.10 scope left as follow-up by
#3643: deletes the dead async cycle-detection/quarantine engine in
`storage/sqlite/queries/session_links.py` (zero production callers since
#3643 ported the equivalent logic into the live writer), and retargets
the three wrong-oracle tests that exercised only that dead engine at the
real production write path (`write_parsed_session_to_archive`).

## Problem
#3643 ported cycle detection + quarantine into the live `session_links`
writer (`write.py`'s `_resolve_outbound_session_links` /
`_resolve_session_graph`), but left
`storage/sqlite/queries/session_links.py`'s equivalent async engine
(`upsert_session_links`, `resolve_session_links_for_session`,
`resolve_unresolved_links_for_child`, `_would_create_cycle`,
`_quarantine_link`, `count_quarantined_session_links`) in place with
zero production callers. Three tests exercised only that dead engine
while implicitly or explicitly claiming to certify production behavior:

- `tests/unit/insights/test_topology_cycle_rejection.py` (a dedicated
621-line file bootstrapping a raw sqlite3 connection and calling the
dead engine directly).
- `test_delegation_direction_matches_real_link_resolver` in
`test_delegations_view.py`, whose docstring explicitly claimed to drive
"the ACTUAL production write path" via the dead engine's functions.
- `test_session_link_resolver_quarantines_cycle` in the property
write-path state machine, which manually inserted a `session_links` row
and called the dead engine's `resolve_unresolved_links_for_child` to
simulate a cycle.

This is exactly the wrong-oracle pattern flagged by the 2026-08-03
structural audit referenced on the bead: tests certifying behavior
production code cannot exhibit.

## Solution
- Deleted the dead write engine from `queries/session_links.py`, keeping
only `list_session_links_for_session` (a genuine production read path
reached via `query_store_archive.py`).
- Deleted `tests/unit/insights/test_topology_cycle_rejection.py`
outright: its scenarios (two-node cycle, self-loop, diamond DAG) are
already covered against the live path by
`test_topology_cycle_quarantine_live.py` (landed with #3643).
- Retargeted `test_delegation_direction_matches_real_link_resolver` to
drive `write_parsed_session_to_archive` directly instead of the dead
engine's `upsert_session_links`/`resolve_session_links_for_session`.
Removed the now-unused `_AsyncSqliteAdapter`/`_AsyncCursorAdapter` shim
built specifically to run the dead engine.
- Retargeted `test_session_link_resolver_quarantines_cycle` to reproduce
the cycle via a real re-ingest through `write_parsed_session_to_archive`
(matching the pattern in `test_topology_cycle_quarantine_live.py`)
instead of manually inserting a row and calling the dead engine.
- Removed now-unused imports (`aiosqlite`, `configure_connection`,
`asyncio` where no longer needed).

### AC status (polylogue-4ts.10)
- AC1 (every write carries `TopologyEdgeStatus`/`method`, no
empty-status writes): satisfied by #3643, unaffected by this PR.
- AC2 (existing rows converge via ordinary derived-tier rebuild, no
bespoke backfill): unaffected, no migration added.
- AC3 (a reader can filter edges by status, composition degrades visibly
on a non-resolved parent): satisfied. `list_session_links_for_session`
already returns `status`; downstream consumers already read it --
`storage/insights/topology/derivation.py` excludes quarantined edges
from unresolved-edge composition, and `insights/transforms.py` threads
`child_link_status` (including `"quarantined"`) onto `SubagentReport`.
Composition already degrades visibly: a quarantined edge's child never
gets `sessions.parent_session_id` set, so it surfaces as its own root
rather than silently composing through the rejected parent. No new
reader surface was needed beyond confirming these existing paths.
- AC4 (live re-measure shows zero empty status/method rows): explicitly
out of scope for this PR -- requires rebuilding the production index
(`polylogue ops reset --index && polylogued run`), a separate operator
action, not a code change.

## Verification
- `devtools test tests/unit/storage/test_delegations_view.py
tests/unit/storage/test_topology_cycle_quarantine_live.py
tests/unit/storage/test_unread_wire_batch_v46.py
tests/unit/storage/test_archive_tiers_write.py
tests/property/test_write_path_state_machine.py` -- 120 passed
- `devtools verify --quick` -- exit 0 (ruff format/check, mypy --strict,
`render all --check`, all lab policy gates)

Ref polylogue-4ts.10

---------

Co-authored-by: Claude <noreply@anthropic.com>
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