diff --git a/backend/src/cyclone/db.py b/backend/src/cyclone/db.py index f66c6e3..f6c0bee 100644 --- a/backend/src/cyclone/db.py +++ b/backend/src/cyclone/db.py @@ -645,6 +645,76 @@ class Two77caAck(Base): ) +# --------------------------------------------------------------------------- +# SP28: per-ACK auto-link join table (claim_acks) +# --------------------------------------------------------------------------- + + +class ClaimAck(Base): + """One row per AK2 set-response / ClaimStatus / TA1 envelope link. + + SP28. The durable record of "this ACK acknowledges this claim (or + set, or batch)". One 999 row carries many AK2s; one 277CA carries + many ClaimStatuses; each gets its own ClaimAck row so the operator + can answer "which claims does this ack acknowledge?" with a single + SELECT on ``claim_acks``. + + See ``docs/superpowers/specs/2026-07-02-cyclone-ack-claim-auto-link-design.md`` + §3.1 for the schema decisions (per-AK2 granularity, the two-pass + join, idempotency via the unique index). + """ + + __tablename__ = "claim_acks" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # FK to claims.id with ON DELETE CASCADE so removing a claim + # drops every link row referencing it. NULLable so TA1 envelope-level + # rows can populate ``batch_id`` instead (the table CHECK constraint + # requires at least one of the two). + claim_id: Mapped[Optional[str]] = mapped_column( + String(64), ForeignKey("claims.id", ondelete="CASCADE"), nullable=True, + ) + # FK to batches.id (a Batch row in the 837 case, or the synthetic + # inbound-batch id when the ack arrived outside the SFTP pipeline). + batch_id: Mapped[Optional[str]] = mapped_column( + String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=True, + ) + # Discriminated union over acks / ta1_acks / two77ca_acks. No FK + # constraint because the three target tables are separate; the + # application enforces the discriminator + the matching row's id. + ack_id: Mapped[int] = mapped_column(Integer, nullable=False) + ack_kind: Mapped[str] = mapped_column(String(8), nullable=False) + ak2_index: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + # The set_control_number the upstream ack ACTUALLY CARRIED + # (== source 837 ST02 for Gainwell batches). Preserved on the link + # row for orphan traceability — the join may have resolved the + # link via the PCN fallback instead, but the operator still sees + # the value the 999/277CA originally carried. + set_control_number: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + # AK5 code (A/E/R/X) for 999, STC category (A1/A2/A3/A4/A6/A7) + # for 277CA, envelope ack_code for TA1. + set_accept_reject_code: Mapped[Optional[str]] = mapped_column(String(8), nullable=True) + linked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + linked_by: Mapped[str] = mapped_column(String(16), nullable=False) + + __table_args__ = ( + Index("ix_claim_acks_claim_id", "claim_id"), + Index("ix_claim_acks_batch_id", "batch_id"), + Index("ix_claim_acks_ack", "ack_kind", "ack_id"), + # Mirror the dedup unique index declared in 0018_claim_acks.sql so + # ``Base.metadata.create_all`` (the test-time safety net) emits the + # same partial-unique constraint that the production migration runner + # applies. Without this a fresh in-memory test DB would not enforce + # idempotency at the DB layer. + Index( + "ux_claim_acks_dedup", + "claim_id", "ack_kind", "ack_id", "ak2_index", + unique=True, + sqlite_where=text("claim_id IS NOT NULL AND ak2_index IS NOT NULL"), + ), + ) + + # --------------------------------------------------------------------------- # SP11: tamper-evident hash-chained audit_log # --------------------------------------------------------------------------- diff --git a/backend/src/cyclone/migrations/0018_claim_acks.sql b/backend/src/cyclone/migrations/0018_claim_acks.sql new file mode 100644 index 0000000..910b8c6 --- /dev/null +++ b/backend/src/cyclone/migrations/0018_claim_acks.sql @@ -0,0 +1,52 @@ +-- version: 18 +-- SP28: per-ACK auto-link join table. +-- +-- Closes the operator gap where every inbound 999 / 277CA / TA1 ack was +-- persisted but never durably linked back to the claim it +-- acknowledges. One row per AK2 set-response for 999, per ClaimStatus +-- for 277CA, per TA1 envelope (with claim_id NULL + batch_id set). +-- +-- Granularity (per-AK2) is preserved by ``ak2_index`` and the unique +-- index ``ux_claim_acks_dedup`` — an auto-link of +-- (claim, 999, ak2_index=3) is idempotent on re-ingest of the same +-- 999 file (the index enforces this at the DB layer; the helper +-- pre-checks to avoid IntegrityError log noise). +-- +-- Notes: +-- * ``claim_id`` is nullable so TA1 envelope-level links to the +-- originating Batch can land here (FK to batches.id). The +-- CHECK constraint makes sure at least one of (claim_id, +-- batch_id) is set on every row — see spec §3.1. +-- * ``set_control_number`` records the value the upstream ACK +-- ACTUALLY CARRIED (== source 837 ST02 for Gainwell batches). It +-- is the orphan-traceability field — the link survives even when +-- the join had to fall back from ST02 to PCN matching. +-- * ``set_accept_reject_code`` carries the AK5 code (A/E/R/X) for +-- 999 or the STC category code (A1/A2/A3/A4/A6/A7 etc.) for 277CA. +-- TA1 stores the envelope-level ack code here (A/R/E). +-- * No FK constraint on ``(ack_kind, ack_id)`` — there are three +-- separate ack tables (``acks``, ``ta1_acks``, ``two77ca_acks``). +-- Application code enforces the discriminator. + +CREATE TABLE claim_acks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + claim_id TEXT REFERENCES claims(id) ON DELETE CASCADE, + batch_id TEXT REFERENCES batches(id) ON DELETE CASCADE, + ack_id INTEGER NOT NULL, + ack_kind TEXT NOT NULL CHECK (ack_kind IN ('999', '277ca', 'ta1')), + ak2_index INTEGER, + set_control_number TEXT, + set_accept_reject_code TEXT, + linked_at DATETIME NOT NULL, + linked_by TEXT NOT NULL CHECK (linked_by IN ('auto', 'manual')), + CHECK ((claim_id IS NOT NULL) OR (batch_id IS NOT NULL)) +); + +CREATE INDEX ix_claim_acks_claim_id ON claim_acks(claim_id); +CREATE INDEX ix_claim_acks_batch_id ON claim_acks(batch_id); +CREATE INDEX ix_claim_acks_ack ON claim_acks(ack_kind, ack_id); + +-- Dedup: an auto-link of (claim, 999, ak2_index=3) is idempotent on re-ingest. +CREATE UNIQUE INDEX ux_claim_acks_dedup + ON claim_acks(claim_id, ack_kind, ack_id, ak2_index) + WHERE claim_id IS NOT NULL AND ak2_index IS NOT NULL;