Files
cyclone/docs/superpowers/specs/2026-06-20-cyclone-edi-features-design.md

8.9 KiB

Sub-project 3 — Backend EDI Features: Design Spec

Date: 2026-06-20 Status: Approved Branch: edi-features

1. Scope

Four backend EDI feature areas extending cyclone beyond the 837P parser and 835 ERA parser already shipped:

  1. Additional 837P validation rules — REF*G1 enforcement for adjustment-frequency claims (R034, gated on cfg.require_ref_g1_for_adjustments); BHT06 transaction-type-code allowlist (R035, gated on cfg.allowed_bht06).
  2. 835 CAS deep-parsing — surface CARC/RARC reason-code labels alongside the existing CAS persistence.
  3. 999 ACK — parse inbound 999s, serialize outbound 999s, optionally auto-generate a 999 in response to an inbound 837P.
  4. 270/271 eligibility — parse 270 requests and 271 responses, serialize 270 requests.

2. Out of scope (deferred to future sub-projects)

  • TA1 / 997 transaction sets (Cyclone doesn't generate them today; deferred)
  • Real-time payer eligibility round-trip (would require an outbound HTTP client + payer credentials)
  • 999 ACK UI drilldown beyond a list page
  • Per-claim ACK row linking in the DB

3. Locked decisions

3.1 CAS reason-code lookup = inline Python dict

A new parsers/cas_codes.py module with ~150 CARC codes (the most common in Colorado Medicaid remits: CO-45, CO-97, PR-1, PR-2, PR-3, CO-16, CO-29, CO-50, CO-109, CO-119, etc.). No external CSV dependency — same shape as claim_status_label(code) at parsers/models_835.py:55-71.

def reason_label(group_code: str, reason_code: str) -> str
def all_known_codes() -> list[tuple[str, str, str]]  # (group, reason, label)

Includes a LAST_UPDATED = "2026-06-20" module constant for refresh tracking.

3.2 999 ACKs get a dedicated acks table

A new migration 0002_acks.sql adds the table rather than reusing batches with a kind="999" + parent_batch_id FK. Cleaner symmetry with the cas_adjustments / matches patterns. The acks table holds the response, not the source batch.

CREATE TABLE acks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    source_batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
    accepted_count INTEGER NOT NULL DEFAULT 0,
    rejected_count INTEGER NOT NULL DEFAULT 0,
    received_count INTEGER NOT NULL DEFAULT 0,
    ack_code TEXT NOT NULL,
    parsed_at DATETIME NOT NULL,
    raw_json TEXT
);
CREATE INDEX ix_acks_source_batch_id ON acks(source_batch_id);

3.3 Auto-999 generation gated by ?ack=true

POST /api/parse-837 gains a new ack: bool = Query(False) parameter. When True, after store.add(record), build a 999 from the validation report:

  • For each claim: AK2 → AK5 with A (accepted) for passed validation, R (rejected) for failed, E (accepted-with-errors) for warnings.
  • Serialize via serialize_999, persist the resulting Ack row.
  • Add body["ack"] = { id, accepted_count, rejected_count, raw_999_text } to the JSON response.

Default behavior unchanged (no auto-ACK).

3.4 270/271 = API-only, no UI, no DB persistence

Ephemeral request/response round-trip. The operator pastes a 270 query, gets a printable 270 file, submits manually, pastes the 271 back. v1 captures the minimum useful surface (EQ inquiry + EB coverage-benefit response); anything more is a separate sub-project.

4. Schemas

4.1 999 models (parsers/models_999.py)

class ParseResult999(BaseModel):
    envelope: Envelope
    functional_groups: list[FunctionalGroupResponse]  # AK1/AK2/AK9
    
class FunctionalGroupResponse(BaseModel):
    ak1: AcknowledgmentHeader                # identifies the source functional group
    sets: list[SetFunctionalGroupResponse]   # one per ST/SE set
    
class AcknowledgmentHeader(BaseModel):
    functional_id_code: str                  # e.g. "HC"
    group_control_number: str               # from GS06
    
class SetFunctionalGroupResponse(BaseModel):
    ak2: SetHeader | None
    segment_errors: list[SegmentError]
    element_errors: list[ElementError]
    ak5: SegmentAcceptReject                 # A / R / E / W
    
class SegmentError(BaseModel):
    ak3: SegmentContext
    loop_id: str | None
    segment_position: int
    
class ElementError(BaseModel):
    ak3_ref: int  # FK by row id
    element_position: int
    
class SegmentAcceptReject(BaseModel):
    code: str  # A=accepted, R=rejected, E=accepted-with-errors, W=warning, X=rejected-all
    
class FunctionalGroupAck(BaseModel):
    ak1: AcknowledgmentHeader
    received_count: int     # AK905
    accepted_count: int     # AK906
    rejected_count: int     # AK907
    ack_code: str           # AK909

4.2 270 request models (parsers/models_270.py)

class ParseResult270(BaseModel):
    envelope: Envelope
    information_source: InformationSource270       # 2100A — payer
    information_receiver: InformationReceiver270   # 2100B — receiver
    subscriber: Subscriber270                       # 2000C
    patient: Patient270 | None                      # 2100C
    inquiries: list[EligibilityBenefitInquiry]      # EQ segments
    
class EligibilityBenefitInquiry(BaseModel):
    service_type_code: str  # EQ01
    coverage_info: str | None
    
class Patient270(BaseModel):
    first_name: str | None
    last_name: str | None
    member_id: str | None
    dob: date | None
    gender: str | None
    relationship: str | None

4.3 271 response models (parsers/models_271.py)

class ParseResult271(BaseModel):
    envelope: Envelope
    information_source: InformationSource271
    information_receiver: InformationReceiver271
    subscriber: Subscriber271
    patient: Patient271 | None
    coverage_benefits: list[CoverageBenefit]   # EB segments, the heart of 271
    
class CoverageBenefit(BaseModel):
    service_type_code: str       # EB01
    coverage_level: str          # EB02 (INDividual, FAMily, etc.)
    service_type_description: str | None  # resolved from inline dict
    benefit_amount: Decimal | None
    in_plan_network: bool | None
    authorization_required: bool | None
    plan_coverage_description: str | None
    time_qualifier: str | None
    benefit_date: date | None
    additional_info: list[BenefitAdditionalInfo]

4.4 acks ORM model

class Ack(Base):
    __tablename__ = "acks"
    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    source_batch_id: Mapped[str] = mapped_column(
        String(64), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False,
    )
    accepted_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    rejected_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    received_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    ack_code: Mapped[str] = mapped_column(String(8), nullable=False)
    parsed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)

5. API additions

Method Path Body / Query Returns
POST /api/parse-999 file upload ParseResult999 + persisted acks row
POST /api/parse-837?ack=true file upload existing response + body["ack"]
GET /api/acks list of Ack records
GET /api/acks/{id} Ack + parsed 999 detail
POST /api/eligibility/request JSON body raw 270 text
POST /api/eligibility/parse-271 text body or file upload ParseResult271 summary

6. UI additions

  • New /acks page (src/pages/Acks.tsx) — list view grouped by source batch with "Download 999" button.
  • useAcks hook (src/hooks/useAcks.ts).
  • api.listAcks() + api.getAck(id) in src/lib/api.ts.
  • Sidebar nav entry with CheckCircle2 icon.
  • Remittances page gains an "Adjustments" expansion row that lists CAS rows with reason-code labels.

No new UI for 270/271 (API-only per §3.4).

7. Migration ordering

0002_acks.sql runs after 0001_initial.sql. db_migrate.run() is idempotent — re-running on a fresh DB is a no-op. create_all is also a no-op when migration is current (per SP2 pattern). No data backfill needed (acks table is new).

8. Test coverage targets

Phase New tests Total target
P1 — 837P rules ~6 ~211 backend
P2 — CAS deep-parsing ~4 ~215 backend
P3 — 999 ACK ~10 backend + ~2 frontend ~225 backend / ~10 frontend
P4 — 270/271 ~4 backend ~229 backend / ~10 frontend
P5 — smoke (verification)

9. Risk areas

  1. 270/271 scope creep. X12 specs are large; we capture only the minimum useful surface.
  2. 999 auto-gen semantics. "Rejected" is loosely defined; we use validation errors as the rejection signal. Document this in the OpenAPI/scalar docs.
  3. CARC codes drift. Inline dict is a snapshot dated 2026-06-20. Plan to refresh annually.
  4. Schema migration ordering. Idempotent on fresh DBs; no data backfill. Confirmed safe.