feat(sp28): add 0018_claim_acks migration + ClaimAck ORM model

The claim_acks join table is the durable record of which inbound
999 / 277CA / TA1 ack acknowledged which claim (or, for TA1, which
originating 837 Batch). One row per AK2 set-response / ClaimStatus
/ envelope. The match granularity is per-AK2 so the operator can
answer 'which claims does this ack acknowledge?' with a single
SELECT and so the ClaimDrawer panel can show per-segment accept /
reject status without re-parsing raw_json.

Schema mirrors spec §3.1:
- claim_id NULLable + batch_id NULLable (TA1 envelope links land
  on batch_id; CHECK enforces at least one populated)
- unique partial index ux_claim_acks_dedup enforces idempotent
  re-ingest of the same 999 file
- set_control_number stores the value the upstream ack ACTUALLY
  CARRIED (== source 837 ST02 for Gainwell batches) for orphan
  traceability — the link survives even when the join had to fall
  back from ST02 to PCN matching

Mirrored on the ORM via Index(..., sqlite_where=text(...)) so
Base.metadata.create_all (the test-time safety net) emits the same
partial-unique constraint as the migration.

Step 1.1/1.2/1.3 of the SP28 implementation plan.
This commit is contained in:
Nora
2026-07-02 11:24:49 -06:00
parent e10d3886c2
commit 9e775c0150
2 changed files with 122 additions and 0 deletions
+70
View File
@@ -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
# ---------------------------------------------------------------------------
@@ -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;