Status: accepted. Implemented 2026-08-25 in kevinvu184/up. Amends ADR 0003 (silver layer): the transform's strictness is unchanged, what happens on a rejection is not.
toSilver rejects any bronze row with a missing or malformed required field, naming the field. The silver cron treated the first rejection as fatal: it threw, the batch did not land, the watermark did not advance, and every row behind the bad one waited until a code fix shipped. That was defended as "costs a rerun, never data" — true, but one unexpected Up payload shape (a new transactionType with an odd field, a resource with a null description) would silently freeze silver and gold for as long as nobody noticed. The medallion contract for silver is to flag or reject invalid records systematically, not to let one record block the layer.
Migration 14 adds silver_rejects (id, reason, last_seen_at, first_rejected_at, last_rejected_at, attempts). The cron pushes each rejection into the batch's reject list, logs a warning with the same message text as before, and lands the batch: good rows, deletes, reject rows and the watermark advance in one transaction. toSilver itself is untouched — it still refuses rather than defaults.
A reject upsert bumps attempts and keeps first_rejected_at. Any batch that transforms the same id cleanly, or deletes it, removes its reject row in the same transaction. So a fixed transform or a corrected upstream payload clears the flag on the next pass with no manual step.
If an id that already has a silver row starts rejecting, silver keeps the old row and the reject row is the flag. Deleting from silver on rejection would make gold marts flicker on a transient upstream quirk; a stale row plus a visible flag was judged the lesser harm. This is a deliberate, documented staleness.
silver_last_run gains rejects (this run) and rejectsOutstanding (table total). A non-zero outstanding count logs a warning at the end of every successful run so the backlog stays visible without failing runs.
The cron only reads bronze rows at or past the watermark, so once a rejected row's last_seen_at is behind the watermark, a transform fix would not reach it until the daily sweep happened to re-bump it. tools/silverReplay.ts re-runs toSilver over every flagged id against current bronze, writes what now passes, re-records what still fails, and leaves the watermark exactly where it was.
A full batch that cannot advance the watermark is an invariant failure, not a data-quality event, and still throws.
Silver degrades per row instead of per layer: a bad payload costs one flagged id, not a frozen pipeline. The price is a new place to look — silver_rejects in the panel, rejectsOutstanding in run state, the warning in logs — and the accepted staleness of a regressed row until it is fixed or deleted.