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