# 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`.

```python
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.

```sql
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`)

```python
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`)

```python
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`)

```python
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

```python
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.