Files
cyclone/docs/superpowers/plans/2026-06-21-cyclone-store-split.md
T
Tyler b9260d9969 docs(plan): implementation plan for CycloneStore split (Step 4)
13 tasks implementing the spec: pre-flight baseline, atomic-move of
store.py into store/__init__.py, 10 module extractions (exceptions,
records, orm_builders, ui, write, batches, claim_detail, acks, backups,
inbox, providers), and final verification + single atomic commit.

Working tree stays dirty through Tasks 1-11; final commit happens at
end of Task 12 per the spec's single-atomic-commit requirement.

Each task ends with a focused test-suite verification (not full baseline
— that's Task 12 Step 1's job) so regressions are caught early.
2026-06-21 10:41:57 -06:00

36 KiB

CycloneStore split (Step 4) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Behaviour-preserving structural split of backend/src/cyclone/store.py (2,412 lines) into a cyclone/store/ subpackage with 13 modules and a thin facade. Zero public API changes, zero test changes, single atomic commit at the end.

Architecture: Module functions for each domain (batches.add_record, inbox.manual_match, etc.); CycloneStore class in __init__.py keeps its current method signatures as 1-line delegations. The facade re-exports every currently-importable name so no caller changes.

Tech Stack: Python 3.13, SQLAlchemy 2.x, Pydantic v2, pytest. Existing test infrastructure (no new tests).

Spec: docs/superpowers/specs/2026-06-21-cyclone-store-split-design.md


File structure

backend/src/cyclone/
├── store.py                       ← DELETED in Task 12
└── store/
    ├── __init__.py                ← facade: re-exports + CycloneStore + store singleton + utcnow
    ├── exceptions.py              ← Task 3
    ├── records.py                 ← Task 4
    ├── orm_builders.py            ← Task 5
    ├── ui.py                      ← Task 6
    ├── write.py                   ← Task 7
    ├── batches.py                 ← Task 8
    ├── claim_detail.py            ← Task 9
    ├── acks.py                    ← Task 10
    ├── backups.py                 ← Task 11
    ├── inbox.py                   ← Task 11
    └── providers.py               ← Task 11

Each module has one clear responsibility. Files that change together live together. The CycloneStore class is the only non-trivial logic that crosses module boundaries, so it lives in __init__.py alongside the facade.


Task 0: Pre-flight — capture baseline

Goal: Snapshot the test suite baseline so we can prove no regressions. Audit private-helper imports so the facade re-exports cover all callers.

Files:

  • Read: backend/src/cyclone/store.py (the file we're splitting)

  • Write: /tmp/cyclone-baseline.txt (test output snapshot)

  • Write: /tmp/cyclone-private-imports.txt (private helper import audit)

  • Step 1: Capture pytest baseline

cd backend && python -m pytest tests/ --tb=line -q 2>&1 | tee /tmp/cyclone-baseline.txt | tail -5

Expected: a line like XXX passed, YY failed, ZZ skipped. Record the counts. Per the api.py split precedent (commit 931782b), expect ~712 passed, ~31 failed (pre-existing cross-test pollution), ~16 skipped.

  • Step 2: Find every private helper imported from cyclone.store in tests
grep -rn "from cyclone.store import _" backend/tests/ | tee /tmp/cyclone-test-private-imports.txt

Expected: lists any test that imports underscore-prefixed names from cyclone.store. These must be re-exported from the facade. (Spot-check confirms _claim_status_from_validation is the only one — batch_diff.py is a source file, see Step 3.)

  • Step 3: Find every private helper imported from cyclone.store in source
grep -rn "from cyclone.store import _" backend/src/ | tee /tmp/cyclone-src-private-imports.txt

Expected output (verified):

backend/src/cyclone/batch_diff.py:32:from cyclone.store import BatchRecord, BatchRecord835, BatchRecord837, _claim_status_from_validation

_claim_status_from_validation must be re-exported from the facade.

  • Step 4: Verify package discovery works on the current tree
cd backend && pip install -e . --quiet 2>&1 | tail -3
python -c "import cyclone.store; print('OK:', cyclone.store.__file__)"

Expected: prints OK: /Users/openclaw/dev/cyclone/backend/src/cyclone/store.py.

  • Step 5: Verify no existing cyclone/store subpackage*
find backend/src/cyclone -maxdepth 1 -name "store*"

Expected: only backend/src/cyclone/store.py.

No commit. Pre-flight only.


Task 1: Create the package skeleton (atomic move, no commit yet)

The trick: copy store.py content into store/__init__.py, delete store.py, verify everything still works via the package. The CycloneStore class and store singleton live in __init__.py for now; they'll be delegated to module functions in Tasks 2-11. The working tree stays dirty through Tasks 2-11 — the single atomic commit happens at the end of Task 12.

Files:

  • Create: backend/src/cyclone/store/__init__.py (verbatim copy of store.py)

  • Delete: backend/src/cyclone/store.py

  • Step 1: Read the current store.py

wc -l backend/src/cyclone/store.py

Expected: 2412.

  • Step 2: Create the package directory
mkdir -p backend/src/cyclone/store
  • Step 3: Copy store.py content to store/__init__.py
cp backend/src/cyclone/store.py backend/src/cyclone/store/__init__.py
  • Step 4: Update the module docstring

The current docstring (lines 1-28) describes cyclone.store as "the module". Edit __init__.py to say "the package" and add a note that this is the facade.

"""SQLAlchemy-backed batch store for parsed X12 files.

The package exposes a single ``CycloneStore`` class and a module-level
singleton (``store``). All persistence flows through SQLAlchemy
sessions via ``db.SessionLocal()()`` (the double-paren "function-style
accessor" established in T2/T4).

This ``__init__.py`` is the facade — it re-exports every public name
from the sibling modules (``records``, ``exceptions``, ``orm_builders``,
``ui``, ``write``, ``batches``, ``claim_detail``, ``acks``, ``backups``,
``inbox``, ``providers``). Implementation lives in those siblings; the
``CycloneStore`` class below keeps its current method signatures as
thin delegations for back-compat.

Public API (preserved from the in-memory version):
  - add(record)
  - get(batch_id) / get_batch(batch_id)
  - list(limit) / all()
  - iter_claims(...) / iter_remittances(...)
  - distinct_providers() / recent_activity(limit)

New API (T12):
  - list_unmatched(kind="both")
  - manual_match(claim_id, remit_id)
  - manual_unmatch(claim_id)
  - AlreadyMatchedError, NotMatchedError, InvalidStateError exception classes

Backward-compat shims for tests that relied on the in-memory internals:
  - ``_lock`` — a no-op ``threading.RLock``. SQLAlchemy handles
    concurrency via the engine's connection pool, but some existing
    tests use it as a context manager around cleanup.
  - ``_batches.clear()`` — wipes all rows from the DB tables so tests
    that depended on a fresh in-memory list per-test get a fresh DB
    state per-test.
"""
  • Step 5: Delete the old store.py
rm backend/src/cyclone/store.py
  • Step 6: Verify imports still work
python -c "
from cyclone.store import (
    CycloneStore, store, BatchRecord, BatchRecord837, BatchRecord835, BatchKind,
    AlreadyMatchedError, NotMatchedError, InvalidStateError, utcnow,
)
print('all imports OK')
"

Expected: all imports OK.

  • Step 7: Verify test suite matches baseline
cd backend && python -m pytest tests/ --tb=line -q 2>&1 | tail -5

Expected: identical counts to /tmp/cyclone-baseline.txt. Any deviation is a regression — investigate before continuing.

No commit yet. The final atomic commit happens in Task 12. The working tree stays dirty through Tasks 2-11. This satisfies the spec's "single atomic commit" requirement.


Task 2: Extract the exceptions module

Files:

  • Create: backend/src/cyclone/store/exceptions.py

  • Modify: backend/src/cyclone/store/__init__.py:58-91 (remove the 3 exception classes, add re-export)

  • Step 1: Create exceptions.py with the 3 exception classes

Copy lines 58-91 of the current __init__.py (which was the old store.py) into exceptions.py. The exact content:

"""Exception types raised by CycloneStore and its domain modules.

These are part of the public API — callers (API endpoints) catch them
and translate to HTTP 409 Conflict responses.
"""


class AlreadyMatchedError(Exception):
    """Raised by ``CycloneStore.manual_match`` when the claim is already paired.

    The claim's ``matched_remittance_id`` is set, so any new pairing would
    clobber an existing match. Callers (the T15 API endpoint) should surface
    this as a 409 Conflict.
    """


class NotMatchedError(Exception):
    """Raised by ``CycloneStore.manual_unmatch`` when the claim has no match.

    Mirrors ``AlreadyMatchedError`` for the unpair operation. Same HTTP
    treatment: 409 Conflict at the API layer.
    """


class InvalidStateError(Exception):
    """Raised when an apply_* pure fn returns a skipped ApplyIntent.

    ``reconcile.apply_payment`` / ``apply_reversal`` may return
    ``skipped=True`` (e.g. claim already in a terminal state, or reversal
    on a non-paid claim). The store surfaces that as ``InvalidStateError``
    rather than silently pairing. The T15 API endpoint maps this to a
    409 Conflict and echoes ``current_state`` and ``activity_kind`` so
    the UI can render a precise message.
    """

    def __init__(self, current_state: str, activity_kind: str = "invalid_state"):
        self.current_state = current_state
        self.activity_kind = activity_kind
        super().__init__(
            f"invalid state {current_state} for apply (kind={activity_kind})"
        )
  • Step 2: Update __init__.py to re-export

Remove lines 58-91 from __init__.py (the three class definitions). Add at the top of __init__.py (after the imports):

from .exceptions import AlreadyMatchedError, NotMatchedError, InvalidStateError
  • Step 3: Verify tests still pass
cd backend && python -m pytest tests/ --tb=line -q 2>&1 | tail -5

Expected: identical counts to baseline.


Task 3: Extract the records module

Files:

  • Create: backend/src/cyclone/store/records.py

  • Modify: backend/src/cyclone/store/__init__.py:94-159 (remove BatchKind, BatchRecord, BatchRecord837, BatchRecord835)

  • Step 1: Create records.py

Copy lines 94-159 of the current __init__.py into records.py. This includes BatchKind (line 94), BatchRecord (lines 102-145), BatchRecord837 (148-152), BatchRecord835 (155-159).

"""Pydantic models for parsed-batch records.

``BatchRecord`` is the union type; ``BatchRecord837`` and ``BatchRecord835``
narrow ``result`` to the parser-specific output types. Construction of
``BatchRecord(kind="837p", ...)`` dispatches to ``BatchRecord837`` via
``__new__`` so isinstance checks downstream narrow ``result`` correctly.
"""

from __future__ import annotations

from datetime import datetime
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, model_validator

from cyclone.parsers.models import ParseResult
from cyclone.parsers.models_835 import ParseResult835


BatchKind = Literal["837p", "835"]


class BatchRecord(BaseModel):
    """One parsed file, with a stable uuid4 id and the full ParseResult.

    ``result`` is a union: ``ParseResult`` for ``kind="837p"`` and
    ``ParseResult835`` for ``kind="835"``. The concrete subclasses
    ``BatchRecord837`` and ``BatchRecord835`` narrow those fields, so
    callers that want type-checked access should use them and check
    ``isinstance`` rather than pattern-matching on ``kind``.

    Constructing ``BatchRecord(kind="837p", ...)`` dispatches to
    ``BatchRecord837``; ``kind="835"`` dispatches to ``BatchRecord835``.
    This lets the union-member narrowing work transparently.
    """

    model_config = ConfigDict(extra="ignore")

    id: str
    kind: BatchKind
    input_filename: str
    parsed_at: datetime  # tz-aware UTC
    result: ParseResult | ParseResult835

    def __new__(
        cls, *args: Any, **kwargs: Any,
    ) -> BatchRecord837 | BatchRecord835 | BatchRecord:
        # Dispatch base-class construction to the right concrete subclass
        # so isinstance checks downstream narrow `result` correctly.
        if cls is BatchRecord:
            kind = kwargs.get("kind")
            if kind is None and args and isinstance(args[0], dict):
                kind = args[0].get("kind")
            if kind == "837p":
                return BatchRecord837(*args, **kwargs)
            if kind == "835":
                return BatchRecord835(*args, **kwargs)
        return super().__new__(cls)

    @model_validator(mode="after")
    def _check_parsed_at_tz(self) -> BatchRecord:
        if self.parsed_at.tzinfo is None:
            raise ValueError(
                "parsed_at must be tz-aware (use datetime.now(timezone.utc))"
            )
        return self


class BatchRecord837(BatchRecord):
    """A parsed 837P (professional claim) batch."""

    kind: Literal["837p"] = "837p"
    result: ParseResult


class BatchRecord835(BatchRecord):
    """A parsed 835 (remittance advice) batch."""

    kind: Literal["835"] = "835"
    result: ParseResult835
  • Step 2: Update __init__.py

Remove the lines containing BatchKind, BatchRecord, BatchRecord837, BatchRecord835 from __init__.py. Add to imports at the top:

from .records import BatchKind, BatchRecord, BatchRecord837, BatchRecord835
  • Step 3: Verify tests still pass
cd backend && python -m pytest tests/ --tb=line -q 2>&1 | tail -5

Expected: identical counts to baseline.


Task 4: Extract the orm_builders module

Files:

  • Create: backend/src/cyclone/store/orm_builders.py

  • Modify: backend/src/cyclone/store/__init__.py (remove the row builders)

  • Step 1: Create orm_builders.py

Copy lines 172-342 from the current __init__.py into orm_builders.py. This includes:

  • _service_dates_from_claim (172-185)
  • _claim_837_row (188-203)
  • _remittance_835_row (205-240)
  • _cas_adjustment_row (242-261)
  • _persist_835_remit (263-329)
  • _claim_status_from_validation (331-342)

Module docstring:

"""ORM row builders — convert parser output into SQLAlchemy ORM rows.

Each function returns an unsaved ORM object (or inserts related rows
within an existing session). They never commit or close the session —
the caller (``write.add_record``, ``acks.add_ack``, etc.) owns the
transaction boundary.
"""
  • Step 2: Update __init__.py

Remove the row-builder functions from __init__.py. Add to imports:

from .orm_builders import _claim_status_from_validation

_claim_status_from_validation is the only one re-exported (for batch_diff.py line 32). The others are module-private — siblings import them directly via from .orm_builders import _service_dates_from_claim etc.

  • Step 3: Verify tests still pass
cd backend && python -m pytest tests/ --tb=line -q 2>&1 | tail -5

Expected: identical counts to baseline.


Task 5: Extract the ui module

Files:

  • Create: backend/src/cyclone/store/ui.py

  • Modify: backend/src/cyclone/store/__init__.py (remove the UI serializers + utcnow)

  • Step 1: Create ui.py

Copy lines 344-855 from the current __init__.py into ui.py. This includes:

  • to_ui_claim (344-370)
  • to_ui_remittance (372-427)
  • to_ui_claim_from_orm (429-487)
  • CLAIM_DETAIL_HISTORY_LIMIT (489-490)
  • _iso_z (492-505)
  • _address_to_ui (507-523)
  • _validation_issues_to_ui (525-542)
  • to_ui_claim_detail (544-679)
  • to_ui_remittance_from_orm (681-715)
  • to_ui_remittance_with_adjustments (717-762)
  • _svc_to_wire_dict (764-784)
  • to_ui_provider (786-811)
  • to_ui_activity_event (813-830)
  • _date_in_bounds (832-855)

Also copy utcnow() (line 162-164) into ui.py. Module docstring:

"""UI serialization helpers — convert ORM rows to API-shaped dicts.

These are the boundary between the persistence layer and the wire format
consumed by the React frontend. Keep them stable: any rename or
shape change ripples to every API consumer.
"""
  • Step 2: Update __init__.py

Remove the UI serializer functions AND utcnow() from __init__.py. Add to imports:

from .ui import utcnow

utcnow is the only one re-exported (for api_helpers.py docstring reference and any external use).

  • Step 3: Verify tests still pass
cd backend && python -m pytest tests/ --tb=line -q 2>&1 | tail -5

Expected: identical counts to baseline.


Task 6: Extract the write module

Files:

  • Create: backend/src/cyclone/store/write.py

  • Modify: backend/src/cyclone/store/__init__.py (remove add, _publish_events_sync, _sync_publish, _run_reconcile)

  • Step 1: Create write.py

Copy lines 898-1107 from the current __init__.py into write.py. This includes:

  • add(record, *, event_bus=None) (898-1040) — rename to module function add_record
  • _publish_events_sync(event_bus, record, claim_ids, remit_ids) (1042-1097) — drop the self. prefix
  • _sync_publish(event_bus, kind, payload) (1100-1107) — drop self. and @staticmethod
  • _run_reconcile(batch_id) (1109-1129) — drop the self. prefix

Rename CycloneStore.add body to module function add_record(record, *, event_bus=None). The body is unchanged except:

  • self._publish_events_sync(...)publish_events_sync(...)
  • self._run_reconcile(...)run_reconcile(...)
  • self._sync_publish(...)_sync_publish(...)

Module docstring:

"""Write path for parsed batches — insert, reconcile, publish.

The single entry point is ``add_record(record, *, event_bus=None)``,
which replaces the body of the previous ``CycloneStore.add`` method.
It owns its own SQLAlchemy session, runs idempotency checks, persists
the batch + child rows, then (for 835 batches) triggers reconciliation
and (if ``event_bus`` is provided) publishes live-tail events.

Reconciliation runs OUTSIDE the persistence session, fail-soft: errors
are logged but do not roll back the persisted batch.
"""
  • Step 2: Update __init__.py CycloneStore class

Replace the add method body:

    def add(self, record, *, event_bus=None):
        """Persist a parsed batch (837P or 835). See cyclone.store.write.add_record."""
        return write.add_record(record, event_bus=event_bus)

Replace the 3 private methods:

    def _publish_events_sync(self, event_bus, record, claim_ids, remit_ids):
        return write.publish_events_sync(event_bus, record, claim_ids, remit_ids)

    def _run_reconcile(self, batch_id):
        return write.run_reconcile(batch_id)

    @staticmethod
    def _sync_publish(event_bus, kind, payload):
        return write._sync_publish(event_bus, kind, payload)

Add import at top of __init__.py:

from . import write
  • Step 3: Verify hot-path tests pass
cd backend && python -m pytest tests/test_parse_999.py tests/test_parse_277ca.py tests/test_api_999.py tests/test_api_277ca.py tests/test_reconcile.py tests/test_reconcile_line_level.py tests/test_api_line_reconciliation.py --tb=short -q

Expected: 100% pass on these 7 files.

  • Step 4: Verify full test suite matches baseline
cd backend && python -m pytest tests/ --tb=line -q 2>&1 | tail -5

Expected: identical counts to baseline.


Task 7: Extract the batches module

Files:

  • Create: backend/src/cyclone/store/batches.py

  • Modify: backend/src/cyclone/store/__init__.py (remove _BatchesShim, _row_to_record, get_batch, get, list, all, load_two_for_diff from CycloneStore)

  • Step 1: Create batches.py

Copy from __init__.py:

  • _BatchesShim class (lines 857-880) — unchanged
  • _row_to_record (1131-1161) — unchanged
  • get_batch(batch_id) (1163-1180) — rename from method to function, drop self
  • get(batch_id) (1182-1194) — rename to get_record
  • list(*, limit) (1196-1205) — rename to list_batches
  • all() (1429-1433) — rename to all_batches
  • load_two_for_diff(batch_ids) (1435-1458) — drop self

Module docstring:

"""Batch read APIs and the legacy _BatchesShim.

The ``_BatchesShim`` class is retained for back-compat with the
``with store._lock: store._batches.clear()`` test-cleanup idiom used
in 17 test files. Its ``clear()`` method wipes all rows from the DB
tables.
"""
  • Step 2: Update __init__.py CycloneStore class

Add import:

from .batches import (
    _BatchesShim, _row_to_record,
    get_batch, get_record, list_batches, all_batches, load_two_for_diff,
)

Replace the 6 method bodies with delegations:

    def get_batch(self, batch_id):
        return get_batch(batch_id)

    def get(self, batch_id):
        return get_record(batch_id)

    def list(self, *, limit=100):
        return list_batches(limit=limit)

    def all(self):
        return all_batches()

    def load_two_for_diff(self, batch_ids):
        return load_two_for_diff(batch_ids)
  • Step 3: Verify tests pass
cd backend && python -m pytest tests/test_api_gets.py tests/test_prodfiles_smoke.py tests/test_api_parse_persists.py tests/test_api_parse_persists_ack.py tests/test_api_claim_detail.py tests/test_api_999.py tests/test_api_ta1.py tests/test_store.py --tb=short -q

Expected: 100% pass on these 8 files (the ones that use _lock/_batches.clear() plus the store-specific tests).


Task 8: Extract the claim_detail module

Files:

  • Create: backend/src/cyclone/store/claim_detail.py

  • Modify: backend/src/cyclone/store/__init__.py (remove the read methods: get_remittance, get_claim_detail, iter_claims, iter_remittances, distinct_providers, recent_activity)

  • Step 1: Create claim_detail.py

Copy from __init__.py:

  • get_remittance(remittance_id) (1207-1266) — drop self
  • get_claim_detail(claim_id) (1268-1427) — drop self
  • iter_claims(...) (1460-1550) — drop self, preserve generator pattern
  • iter_remittances(...) (1552-1643) — drop self, preserve generator pattern
  • distinct_providers() (1645-1668) — drop self
  • recent_activity(*, limit) (1670-1691) — drop self

Module docstring:

"""Claim- and remittance-detail read APIs.

Includes the only large non-write query in the store:
``get_claim_detail`` (~160 lines) which joins Claim + CasAdjustment +
ActivityEvent history for the right-drawer UI.
"""
  • Step 2: Update __init__.py CycloneStore class

Add import:

from .claim_detail import (
    get_remittance, get_claim_detail,
    iter_claims, iter_remittances,
    distinct_providers, recent_activity,
)

Replace the 6 method bodies with delegations:

    def get_remittance(self, remittance_id):
        return get_remittance(remittance_id)

    def get_claim_detail(self, claim_id):
        return get_claim_detail(claim_id)

    def iter_claims(self, *args, **kwargs):
        return iter_claims(*args, **kwargs)

    def iter_remittances(self, *args, **kwargs):
        return iter_remittances(*args, **kwargs)

    def distinct_providers(self):
        return distinct_providers()

    def recent_activity(self, *, limit=200):
        return recent_activity(limit=limit)
  • Step 3: Verify tests pass
cd backend && python -m pytest tests/test_api_claim_detail.py tests/test_store_claim_detail.py tests/test_lane_filter_acknowledged.py --tb=short -q

Expected: 100% pass.


Task 9: Extract the acks module

Files:

  • Create: backend/src/cyclone/store/acks.py

  • Modify: backend/src/cyclone/store/__init__.py (remove the 9 ACK methods)

  • Step 1: Create acks.py

Copy from __init__.py:

  • add_ack(...) (1693-1727) → add_999_ack
  • list_acks() (1729-1736)
  • get_ack(ack_id) (1738-1743)
  • add_ta1_ack(...) (1745-1783)
  • list_ta1_acks() (1785-1796)
  • get_ta1_ack(ack_id) (1798-1803)
  • add_277ca_ack(...) (1805-1836)
  • list_277ca_acks() (1838-1845)
  • get_277ca_ack(ack_id) (1847-1852)

Drop self from each. Module docstring:

"""ACK persistence — 999 / TA1 / 277CA acknowledgements.

Each ACK type has its own ORM row (Ack / Ta1Ack / Two77caAck). The
write methods are simple inserts; the list/get methods are simple
queries. Fail-soft: persistence errors are logged but do not block
parsing.
"""
  • Step 2: Update __init__.py CycloneStore class

Add import:

from .acks import (
    add_999_ack, list_acks, get_ack,
    add_ta1_ack, list_ta1_acks, get_ta1_ack,
    add_277ca_ack, list_277ca_acks, get_277ca_ack,
)

Replace the 9 method bodies with delegations. (Method names on the class stay the same as today — only the module-function names use the type prefix for clarity.)

    def add_ack(self, **kwargs):
        return add_999_ack(**kwargs)

    def list_acks(self):
        return list_acks()

    def get_ack(self, ack_id):
        return get_ack(ack_id)

    def add_ta1_ack(self, **kwargs):
        return add_ta1_ack(**kwargs)

    def list_ta1_acks(self):
        return list_ta1_acks()

    def get_ta1_ack(self, ack_id):
        return get_ta1_ack(ack_id)

    def add_277ca_ack(self, **kwargs):
        return add_277ca_ack(**kwargs)

    def list_277ca_acks(self):
        return list_277ca_acks()

    def get_277ca_ack(self, ack_id):
        return get_277ca_ack(ack_id)
  • Step 3: Verify tests pass
cd backend && python -m pytest tests/test_api_parse_persists_ack.py tests/test_api_ta1.py tests/test_acks.py tests/test_api_999.py tests/test_apply_277ca_rejections.py --tb=short -q

Expected: 100% pass on these 5 ACK-related test files.


Task 10: Extract the backups module

Files:

  • Create: backend/src/cyclone/store/backups.py

  • Modify: backend/src/cyclone/store/__init__.py (remove add_backup_pending)

  • Step 1: Create backups.py

Copy add_backup_pending(*, filename, backup_dir) (lines 1854-1878) from __init__.py. Drop self. Module docstring:

"""Backup-pending marker persistence.

Used by ``cyclone.backup_service`` to mark files that need backing up
after parsing. Simple insert into the DbBackup table.
"""
  • Step 2: Update __init__.py CycloneStore class

Add import:

from .backups import add_backup_pending

Replace the method body:

    def add_backup_pending(self, *, filename, backup_dir):
        return add_backup_pending(filename=filename, backup_dir=backup_dir)
  • Step 3: Verify tests pass
cd backend && python -m pytest tests/test_backup_service.py tests/test_api_backup.py --tb=short -q

Expected: 100% pass.


Task 11: Extract the inbox + providers modules

Files:

  • Create: backend/src/cyclone/store/inbox.py

  • Create: backend/src/cyclone/store/providers.py

  • Modify: backend/src/cyclone/store/__init__.py (remove 3 inbox + 7 provider/payer methods)

  • Step 1: Create inbox.py

Copy from __init__.py:

  • list_unmatched(*, kind) (1880-1949) — drop self
  • manual_match(claim_id, remit_id) (1951-2068) — drop self, import reconcile at module top
  • manual_unmatch(claim_id) (2070-2163) — drop self

inbox.py module docstring:

"""Inbox lane state and manual match/unmatch operations.

``list_unmatched`` returns the 5-lane inbox view (unmatched claims +
unmatched remits). ``manual_match`` and ``manual_unmatch`` invoke the
reconcile pure functions and persist the resulting Match row (or
remove it). Invalid state transitions surface as ``InvalidStateError``.
"""

Add at the top of inbox.py:

from cyclone import db, reconcile
from .exceptions import InvalidStateError
  • Step 2: Create providers.py

Copy from __init__.py:

  • list_providers(*, is_active) (2165-2174)
  • get_provider(npi) (2176-2181)
  • upsert_provider(provider) (2183-2214)
  • list_payers(*, is_active) (2216-2224)
  • get_payer_config(payer_id, transaction_type) (2226-2230)
  • get_clearhouse() (2232-2250)
  • ensure_clearhouse_seeded() (2252-end-of-class)
  • _provider_orm_to_dict(row) (2382-2397)
  • _payer_orm_to_dict(row) (2400-2410)

Drop self from each. Module docstring:

"""Provider, payer, and clearhouse configuration reads and upserts.

The ``providers`` and ``payers`` modules in ``cyclone`` provide the
ORM-row DTOs; this module is the read/upsert surface over them.
``ensure_clearhouse_seeded`` is called at startup to insert the
default Clearhouse row if missing.
"""

Add at the top of providers.py:

from cyclone import db
from cyclone.providers import Clearhouse, Payer, Provider
  • Step 3: Update __init__.py CycloneStore class

Add imports:

from .inbox import list_unmatched, manual_match, manual_unmatch
from .providers import (
    list_providers, get_provider, upsert_provider, list_payers,
    get_payer_config, get_clearhouse, ensure_clearhouse_seeded,
)

Replace the 10 method bodies with delegations:

    def list_unmatched(self, *, kind="both"):
        return list_unmatched(kind=kind)

    def manual_match(self, claim_id, remit_id):
        return manual_match(claim_id, remit_id)

    def manual_unmatch(self, claim_id):
        return manual_unmatch(claim_id)

    def list_providers(self, *, is_active=True):
        return list_providers(is_active=is_active)

    def get_provider(self, npi):
        return get_provider(npi)

    def upsert_provider(self, provider):
        return upsert_provider(provider)

    def list_payers(self, *, is_active=True):
        return list_payers(is_active=is_active)

    def get_payer_config(self, payer_id, transaction_type):
        return get_payer_config(payer_id, transaction_type)

    def get_clearhouse(self):
        return get_clearhouse()

    def ensure_clearhouse_seeded(self):
        return ensure_clearhouse_seeded()
  • Step 4: Verify hot-path tests pass
cd backend && python -m pytest tests/test_inbox_state.py tests/test_inbox_lanes.py tests/test_inbox_endpoints.py tests/test_inbox_endpoints_sp7.py tests/test_payer_rejected_acknowledge.py tests/test_store_reconcile.py tests/test_reconcile.py tests/test_reconcile_line_level.py tests/test_api_line_reconciliation.py tests/test_providers_seed.py --tb=short -q

Expected: 100% pass on these 10 hot-path files.


Task 12: Final verification + cleanup

  • Step 1: Run full test suite, compare to baseline
cd backend && python -m pytest tests/ --tb=line -q 2>&1 | tail -5

Expected: identical counts to /tmp/cyclone-baseline.txt line. If any deviation, halt and investigate.

  • Step 2: Import sanity check
python -c "
from cyclone.store import (
    CycloneStore, store, BatchRecord, BatchRecord837, BatchRecord835, BatchKind,
    AlreadyMatchedError, NotMatchedError, InvalidStateError, utcnow,
    _claim_status_from_validation,
)
print('all public imports OK')

# Verify the singleton has the expected methods
import inspect
store_methods = [m for m in dir(store) if not m.startswith('__')]
expected = {'add', 'get', 'get_batch', 'list', 'all', 'get_claim_detail',
            'get_remittance', 'iter_claims', 'iter_remittances',
            'distinct_providers', 'recent_activity', 'add_ack', 'list_acks',
            'get_ack', 'add_ta1_ack', 'list_ta1_acks', 'get_ta1_ack',
            'add_277ca_ack', 'list_277ca_acks', 'get_277ca_ack',
            'add_backup_pending', 'list_unmatched', 'manual_match',
            'manual_unmatch', 'list_providers', 'get_provider',
            'upsert_provider', 'list_payers', 'get_payer_config',
            'get_clearhouse', 'ensure_clearhouse_seeded', 'load_two_for_diff'}
missing = expected - set(store_methods)
assert not missing, f'missing methods: {missing}'
print('all 32 public methods present on singleton')
"

Expected: all public imports OK and all 32 public methods present on singleton.

  • Step 3: Live smoke test on 8 endpoints

Per the api.py split precedent (commit 931782b), hit each endpoint and verify response shape:

# Start the backend
cd backend && python -m cyclone &
sleep 3

# 8 endpoint checks (curl + jq, then kill backend)
# 1. POST /api/parse-999 (valid fixture → ack_code=A)
# 2. POST /api/parse-999 (rejected fixture → ack_code=R)
# 3. POST /api/parse-277ca (valid → 200 with 3 claim statuses)
# 4. GET /api/inbox?lane=unmatched (list_unmatched shape)
# 5. POST /api/inbox/match (manual_match happy path)
# 6. POST /api/inbox/unmatch (manual_unmatch happy path)
# 7. GET /api/batches + /api/batches/{id} (get_batch shape)
# 8. GET /api/claims/{id} (get_claim_detail shape)

pkill -f "python -m cyclone"

Document the smoke results in the commit message.

  • Step 4: Final commit — the single atomic commit

The working tree should currently show all of Tasks 1-11 as uncommitted edits (the atomic-move of Task 1 + the 10 extractions of Tasks 2-11). Review the diff and commit once.

cd /Users/openclaw/dev/cyclone

# Review what's about to be committed
git status

# Confirm the only changes are backend/src/cyclone/store* — no source
# outside the store package, no test files, no docs.
git diff --stat

# The expected diff: store.py deleted, store/__init__.py modified,
# store/{exceptions,records,orm_builders,ui,write,batches,claim_detail,
#         acks,backups,inbox,providers}.py all created.

git add -A backend/src/cyclone/
git commit -m "refactor(store): split store.py into cyclone/store/ subpackage

Step 4 of the file-split refactor series. CycloneStore facade now
delegates to 11 sibling modules:

  exceptions   - AlreadyMatchedError, NotMatchedError, InvalidStateError
  records      - BatchKind, BatchRecord (837/835 subclasses)
  orm_builders - _claim_837_row, _remittance_835_row, _persist_835_remit,
                 _claim_status_from_validation
  ui           - to_ui_*, _iso_z, _address_to_ui, utcnow
  write        - add_record, publish_events_sync, run_reconcile
  batches      - get_batch, get_record, list_batches, _BatchesShim
  claim_detail - get_remittance, get_claim_detail, iter_claims, iter_remittances
  acks         - add_999_ack, add_ta1_ack, add_277ca_ack + reads
  backups      - add_backup_pending
  inbox        - list_unmatched, manual_match, manual_unmatch
  providers    - list_providers, upsert_provider, ensure_clearhouse_seeded

CycloneStore class keeps its current method signatures as 1-line
delegations. The store singleton and all public imports
(CycloneStore, store, BatchRecord, exception types, utcnow,
_claim_status_from_validation) continue to work unchanged.

Test suite: identical pass/fail/skip counts to pre-split baseline.
Smoke: 8 endpoints return the same shapes they did before the split.

Zero public API changes, zero test changes. Behaviour-preserving."

Self-review

1. Spec coverage:

Spec section Task
Target module layout Tasks 2-11 (each module is one task)
CycloneStore class shape (3 categories) Tasks 6-11
Naming collision table Tasks 7-11 (renamed to module functions in those tasks)
Public API preservation Task 12 Step 2 verifies the 11 currently-imported names
Migration plan Task 1 (atomic move) + Tasks 2-11 (extractions) + Task 12 (final commit)
Test strategy (4 tiers) Task 12 Steps 1-3
Risk register (R1-R10) Each task's Step 3 verifies tests pass; R1 (no cycles) is satisfied because siblings never import from __init__.py
Pre-flight Task 0
Post-flight Task 12 Steps 1-3
Out of scope Plan respects all 10 out-of-scope items

2. Placeholder scan: No "TBD", "TODO", "fill in details", "similar to Task N" in any task. Code is shown where the task requires it. Module docstrings are shown verbatim.

3. Type consistency: All method signatures preserved. Module function renames (list_batches, all_batches, get_record, add_999_ack, add_record) match between the create-task and the delegation-task in each pair. The CycloneStore method names (store.list, store.add, store.add_ack) are unchanged from current behavior.

4. Gap check: The plan doesn't explicitly call out extracting utcnow() (Task 5 includes it in ui.py). It doesn't explicitly call out the singleton setup line — that's preserved as store = CycloneStore() at the bottom of __init__.py, untouched through all tasks. Both are implicit but discoverable.